openvibers/openvibe.blog Commits
The blog's home shows what is on the blog: the numbers, the newest posts as cards, the week's changes across OpenVibe, the AI desks' latest, topics, members' posts, how it works, questions and links around the site (#71)
Changes
@@@ -70,3 +70,51 @@ table.posts th, table.posts td { text-align: left; padding: 6px 8px; border-bott.page .sc-sec { margin-top: 36px; }.page .sc-sec + #posts { display: block; margin-top: 40px; }.page .sc-ic .ov-icon { --ovi-size: 28px !important; }++++/* The home (render/home.js): a wider column, the numbers beside the hero, post cards, the week by site, desks, topics. */++body.blog-home .page { max-width: 1120px; }++.bh-panel { padding: 18px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-card, #131c2e); }++.page .bh-panel-h { margin: 0 0 12px; font-size: 1rem; }++.bh-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }++.bh-stats div { display: flex; flex-direction: column-reverse; padding: 10px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-primary, #0a0f1c); }++.bh-stats dt { font-size: 13px; line-height: 1.35; color: var(--text-secondary, #96a7c2); }++.bh-stats dd { margin: 0; font-size: 1.55rem; font-weight: 800; line-height: 1.2; font-variant-numeric: tabular-nums; }++.bh-sec .feeds { margin: 0 0 14px; font-size: .95rem; }++.bh-cards, .bh-desks { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); }++.bh-card, .bh-desks li { display: flex; flex-direction: column; min-width: 0; padding: 14px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }++.bh-card h3, .bh-desks h3 { margin: 0 0 4px; font-size: 1.05rem; line-height: 1.35; }++.bh-card h3 a, .bh-desks h3 a { color: var(--text-primary, #e6edf7); text-decoration: none; }++.bh-card h3 a:hover, .bh-desks h3 a:hover { text-decoration: underline; }++.bh-card .meta, .bh-desks .meta { margin: 0; color: var(--text-secondary, #96a7c2); }++.bh-sum { margin: 6px 0 0; font-size: .95rem; color: var(--text-secondary, #96a7c2); display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }++.bh-card .tags { margin: auto 0 0; padding-top: 8px; font-size: .9rem; }++.bh-desks .meta { margin-top: auto; padding-top: 8px; }++.bh-desk { margin: 0 0 6px; font-size: .8rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }++.bh-bars { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }++.bh-bars li { display: grid; grid-template-columns: minmax(0, 11rem) minmax(0, 1fr) auto; align-items: center; gap: 12px; font-size: .95rem; }++.bh-bars a { overflow-wrap: anywhere; }++.bh-bar { height: 10px; border-radius: 999px; background: var(--bg-tertiary, #172238); overflow: hidden; }++.bh-bar span { display: block; height: 100%; border-radius: inherit; background: var(--accent, #3b82f6); }++.bh-n { color: var(--text-secondary, #96a7c2); font-variant-numeric: tabular-nums; white-space: nowrap; }++.bh-more { margin: 14px 0 0; }++.bh-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }++.bh-chips a { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border: 1px solid var(--border-light, #2c3d5c); border-radius: 999px; text-decoration: none; font-size: .95rem; }++.bh-chips a:hover { border-color: var(--accent, #3b82f6); }++.bh-chips span { color: var(--text-secondary, #96a7c2); font-size: .85rem; }++.bh-faq details { border-bottom: 1px solid var(--border, #1f2d47); padding: 10px 0; }++.bh-faq summary { cursor: pointer; font-weight: 600; }++.bh-faq details p { margin: 8px 0 0; color: var(--text-secondary, #96a7c2); max-width: 760px; }++.bh-links { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr)); }++.bh-links a { display: block; height: 100%; padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; }++.bh-links a:hover { border-color: var(--accent, #3b82f6); }++.bh-links b { display: block; }++.bh-links small { display: block; color: var(--text-secondary, #96a7c2); font-size: .88rem; line-height: 1.45; }++@media (max-width: 560px) {++ .bh-bars li { grid-template-columns: minmax(0, 1fr) auto; }++ .bh-bars .bh-bar { grid-column: 1 / -1; grid-row: 2; }++ .bh-stats dd { font-size: 1.3rem; }++ /* Phones: a post card is its title, date and tags (the titles say what is inside); desks keep their one-line dek. */++ .bh-card { padding: 12px 14px; }++ .bh-card .bh-sum { display: none; }++ .bh-desks .bh-sum { -webkit-line-clamp: 2; line-clamp: 2; }++}nnew file mode 100644@@@ -0,0 +1,53 @@++'use strict';++/**++ * What the home page shows besides the official blog's own posts, all read from Blog's database and all public:++ *++ * stats() public posts and the blogs and writers behind them, AI desk posts, and the changes the++ * changelog logged across the network in the last seven days (and how many sites they touched)++ * weekChanges(n) those seven days' changes by site, most first++ * topTags(n) the tags on the most public posts++ * memberPosts(n) the newest public posts on members' blogs++ *++ * Only public, published posts on active blogs count, the rule the listings use (posts.listPublished): the home is cached++ * for everyone, so nothing here may depend on who is looking.++ */++const WEEK_MS = 7 * 24 * 3600 * 1000;++const PUBLIC_POST = "p.state = 'published' AND p.visibility = 'public' AND EXISTS (SELECT 1 FROM blogs b WHERE b.id = p.blog_id AND b.status = 'active')";++++function createHome({ store }) {++ const db = store.db;++ const since = () => new Date(store.now() - WEEK_MS).toISOString();++ const q = {++ stats: db.prepare(`SELECT (SELECT COUNT(*) FROM blog_posts p WHERE ${PUBLIC_POST}) AS posts,++ (SELECT COUNT(DISTINCT p.blog_id) FROM blog_posts p WHERE ${PUBLIC_POST}) AS blogs,++ (SELECT COUNT(DISTINCT p.author_subject) FROM blog_posts p WHERE ${PUBLIC_POST}) AS writers,++ (SELECT COUNT(*) FROM blog_desk_posts WHERE status = 'published') AS desk_posts,++ (SELECT COUNT(*) FROM changelog_entries WHERE deployed_at >= ?) AS week_changes,++ (SELECT COUNT(DISTINCT service) FROM changelog_entries WHERE deployed_at >= ?) AS week_sites`),++ week: db.prepare('SELECT service, COUNT(*) AS n FROM changelog_entries WHERE deployed_at >= ? GROUP BY service ORDER BY n DESC, service LIMIT ?'),++ tags: db.prepare(`SELECT t.slug, t.name, COUNT(*) AS n FROM blog_term_links l JOIN blog_terms t ON t.id = l.term_id++ JOIN blog_posts p ON p.id = l.entity_id WHERE t.vocabulary = 'tag' AND ${PUBLIC_POST}++ GROUP BY t.slug, t.name ORDER BY n DESC, t.slug LIMIT ?`),++ memberPosts: db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs mb ON mb.id = p.blog_id AND mb.kind = 'member'++ WHERE ${PUBLIC_POST} ORDER BY p.published_at DESC, p.id DESC LIMIT ?`),++ };++ const n = (v) => Number(v) || 0;++ return {++ async stats() {++ const t = since();++ const r = (await q.stats.get(t, t)) || {};++ return { posts: n(r.posts), blogs: n(r.blogs), writers: n(r.writers), deskPosts: n(r.desk_posts), weekChanges: n(r.week_changes), weekSites: n(r.week_sites) };++ },++ async weekChanges(limit = 10) {++ return (await q.week.all(since(), Math.min(50, Math.max(1, limit)))).map((r) => ({ service: r.service, changes: n(r.n) }));++ },++ async topTags(limit = 24) {++ return (await q.tags.all(Math.min(100, Math.max(1, limit)))).map((r) => ({ slug: r.slug, name: r.name, posts: n(r.n) }));++ },++ async memberPosts(limit = 6) {++ return await q.memberPosts.all(Math.min(50, Math.max(1, limit)));++ },++ };++}++++module.exports = { createHome, WEEK_MS };@@@ -26,12 +26,19 @@ const authorship = require('openvibe-publishing/authorship');const cache = require('openvibe-shared/cache-policy');const { renderPage, SITE_SUMMARY } = require('../render/layout');const pages = require('../render/pages');++const homePage = require('../render/home');++const { createHome } = require('../domain/home');++++// The home's own words in search results and share cards (the official blog's later pages keep the blog's).++const HOME_TITLE = 'OpenVibe.Blog: start a blog, read what OpenVibe ships';++const HOME_DESCRIPTION = 'Start a blog with drafts, scheduling, feeds and comments on your OpenVibe account. Read OpenVibe\'s patch notes and AI digests of the news.';const { csrfToken, checkCsrf } = require('../auth/forms');const PER_PAGE = 15;function createPublicRoutes(ctx) {-- const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers, limits } = ctx;++ const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers, limits, desks } = ctx;++ const homeData = ctx.home || createHome({ store });const router = express.Router();router.use(viewers.middleware({ services: false }));@@@ -82,26 +89,6 @@ function createPublicRoutes(ctx) {// ── Blog front pages ────────────────────────────────────-- /** The product's own words above the official blog's posts: only features Blog has today. */-- function blogShowcase() {-- return showcase.hero({-- eyebrow: 'OpenVibe.Blog',-- title: 'Write on', accent: 'OpenVibe',-- lede: 'A blog of your own with drafts, scheduling, feeds and comments. Sign in once with your OpenVibe account; anyone can read without one.',-- actions: [{ label: 'Start writing', href: '/write', primary: true }, { label: 'Read the OpenVibe blog', href: '#posts' }, { label: 'AI desks', href: '/desks' }],-- }) + showcase.features({-- title: 'What your blog gets',-- items: [-- { icon: 'ov:blog', title: 'Drafts and revisions', text: 'Every save is a revision you can go back to; publish when it is ready.' },-- { icon: 'ov:history', title: 'Scheduling', text: 'Pick the minute a post goes live.' },-- { icon: 'ov:news', title: 'Feeds', text: 'RSS, Atom and JSON Feed for every blog, series and category.' },-- { icon: 'ov:community', title: 'Comments', text: 'Threads on OpenVibe.Community, moderated by you.' },-- { icon: 'ov:theme', title: 'Your theme', text: 'Pick a theme preset and your blog wears it.' },-- { icon: 'ov:vip', title: 'Members-only posts', text: 'Your OpenVibe.VIP members read the whole post; everyone else sees a teaser.' },-- ],-- }) + '<div id="posts"></div>';-- }--async function blogFront(req, res, blog, { home = false } = {}) {const page = pageNumber(req);const scope = await access.listingScope(store, req.viewer, blog);@@@ -125,6 +112,27 @@ function createPublicRoutes(ctx) {};const seriesShown = [];for (const sr of await blogs.series(blog)) if (await shows({ seriesId: sr.id })) seriesShown.push(sr);++ const blogLd = blog.kind === 'official'++ ? { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined, publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' } }++ : { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined };++ // The network's home (the official blog's first page): what OpenVibe.Blog is for, then everything it has to read.++ if (home && pager.page === 1) {++ const [stats, week, latestDesks, tags, memberRows, seriesCounts] = await Promise.all([++ homeData.stats(), homeData.weekChanges(10), desks ? desks.latest() : new Map(), homeData.topTags(24), homeData.memberPosts(6),++ Promise.all(seriesShown.map(async (sr) => ({ title: sr.title, url: `/@${blog.handle}/series/${encodeURIComponent(sr.slug)}`, posts: (await posts.listPublished({ blogId: blog.id, seriesId: sr.id, ...scope, limit: 1 })).total }))),++ ]);++ const memberItems = memberRows.length ? await reading.listItems(memberRows) : [];++ return send(req, res, 200, {++ title: blog.title, fullTitle: HOME_TITLE, description: HOME_DESCRIPTION, summary: SITE_SUMMARY,++ decision: pageDecision(canonical, { empty: false, query: ['page'] }),++ canonical, feeds, theme: { slug: blog.theme, vars: blogs.themeVars(blog) }, lang: blog.language,++ next: pager.next ? pager.next.href : null,++ jsonLd: [blogLd, ...homePage.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), items })],++ styles: [showcase.STYLESHEET], bodyClass: 'blog-home',++ body: homePage.homeBody({ blog, items, pager, feeds, stats, week, latestDesks, tags, series: seriesCounts, memberItems, signedIn: req.viewer.kind === 'user' })++ + frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }),++ }, { cacheable: !scope.restricted && !scope.restrictedAuthor });++ }send(req, res, 200, {title: blog.title,description: blog.description,@@@ -137,17 +145,12 @@ function createPublicRoutes(ctx) {lang: blog.language,prev: pager.prev ? pager.prev.href : null,next: pager.next ? pager.next.href : null,-- jsonLd: [blog.kind === 'official'-- ? { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined, publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' } }-- : { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined }],-- // The network's blog home opens with what OpenVibe.Blog is for (openvibe-shared/showcase), then the posts.-- ...(home && pager.page === 1 ? { styles: [showcase.STYLESHEET] } : {}),-- body: (home && pager.page === 1 ? blogShowcase() : '') + pages.blogIndex({++ jsonLd: [blogLd],++ body: pages.blogIndex({blog, blogUrl: `/@${blog.handle}`, items, pager, feeds,series: seriesShown, categories: await prune(await reading.categoriesTree(blog)),canWrite: await access.canWrite(store, req.viewer, blog, 'create'),-- showcase: home && pager.page === 1,-- }) + (home && pager.page === 1 ? frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }) : ''),++ }),}, { cacheable: !scope.restricted && !scope.restrictedAuthor });}nnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++/**++ * The home page (the first page of the official blog at /): what OpenVibe.Blog is for, then real content only. The++ * official blog's newest posts, the week's changes across the network, the AI desks' latest posts, topics, members'++ * posts, how a blog works, questions and the ways around the site. A section with nothing to show is left out.++ */++const ssr = require('openvibe-publishing/ssr');++const showcase = require('openvibe-shared/showcase');++const catalog = require('../desks/catalog');++++const { html: h, raw } = ssr;++const dateLabel = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);++const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString(), { label: dateLabel(v) || undefined }));++const num = (n) => Number(n || 0).toLocaleString('en-US');++const plural = (n, one, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;++++// A changelog service id as people know the site ("media-hub" is OpenVibe.Video's server, "openre" is OpenRestream).++const SITE_NAMES = { ai: 'OpenVibe.AI', 'media-hub': 'OpenVibe.MediaHub', openre: 'OpenRestream', vip: 'OpenVibe.VIP', sdk: 'OpenVibe.SDK' };++const siteName = (id) => SITE_NAMES[id] || `OpenVibe.${String(id).charAt(0).toUpperCase()}${String(id).slice(1)}`;++++// The questions the home answers, on the page and as FAQPage (exactly what is shown).++const FAQ = [++ { q: 'Who can start a blog?', a: 'Anyone with an OpenVibe account. Sign in, pick your blog\'s name and start writing; reading needs no account at all.' },++ { q: 'Can other people write on my blog?', a: 'Yes. Add editors, who can publish anyone\'s posts, and authors, who write their own. You stay the owner.' },++ { q: 'Can AI help me write?', a: 'Ask for a draft from a topic and it arrives as a draft marked as written by AI, with its sources attached. It stays out of search engines until you review it.' },++ { q: 'Can I write for my members only?', a: 'Yes. Your OpenVibe.VIP members read the whole post; everyone else sees a teaser.' },++ { q: 'What are the AI desks?', a: 'Digests that OpenVibe.AI writes from the news, recalls, deals, hosting plans and releases OpenVibe.Sources collects, plus one satire column. Every digest links the items it was written from, says it was written by AI, and is kept out of search engines.' },++ { q: 'Where do the patch notes come from?', a: 'Every change deployed to an OpenVibe site is logged from its repository, and a Patch notes post rounds them up once enough have shipped.' },++];++++function statsPanel(stats) {++ const tiles = [++ stats.posts ? ['Posts published', stats.posts] : null,++ stats.deskPosts ? ['AI desk posts', stats.deskPosts] : null,++ stats.weekChanges ? ['Changes shipped this week', stats.weekChanges] : null,++ stats.weekSites ? ['Sites changed this week', stats.weekSites] : null,++ ].filter(Boolean);++ if (!tiles.length) return '';++ return String(h`<div class="bh-panel"><h2 class="bh-panel-h">On the blog</h2><dl class="bh-stats">${tiles.map(([k, v]) => h`<div><dt>${k}</dt><dd>${num(v)}</dd></div>`)}</dl></div>`);++}++++function postCard(item) {++ const { post, rev, url, author, tags, badge } = item;++ const summary = rev.fields.summary || ssr.markdownToText(rev.content, 220);++ return h`<li class="bh-card"><h3><a href="${url}">${rev.fields.title}</a>${badge ? h` <span class="badge">${badge}</span>` : ''}</h3>++<p class="meta">${time(post.published_at)}${author ? h` · ${author.name}` : ''}</p>${summary ? h`<p class="bh-sum">${summary}</p>` : ''}++${tags && tags.length ? h`<p class="tags">${tags.slice(0, 4).map((t) => h`<a href="${t.url}">#${t.name}</a> `)}</p>` : ''}</li>`;++}++++/**++ * o: blog, items (the official blog's first page), pager, feeds, stats, week ([{ service, changes }]), latestDesks++ * (Map desk → post), tags ([{ slug, name, posts }]), series ([{ title, url, posts }]), memberItems, signedIn++ */++function homeBody(o) {++ const { blog, items, pager, feeds, stats, week = [], latestDesks = new Map(), tags = [], series = [], memberItems = [], signedIn = false } = o;++ const start = { label: signedIn ? 'Start writing' : 'Sign in and start writing', href: '/write', primary: true };++ const top = week.length ? week[0].changes : 0;++ const desks = catalog.ALL.map((d) => ({ desk: d, post: latestDesks.get(d.key) })).filter((x) => x.post);++ return String(h`${raw(showcase.hero({++ eyebrow: 'OpenVibe.Blog',++ title: 'Write on', accent: 'OpenVibe',++ lede: 'A blog of your own with drafts, scheduling, feeds and comments, on the OpenVibe account you already have. Anyone can read without one.',++ actions: [start, { label: 'Read the OpenVibe blog', href: '#posts' }, { label: 'AI desks', href: '/desks' }],++ aside: stats ? { html: statsPanel(stats) } : undefined,++ }))}++<section class="sc-sec bh-sec" id="posts" aria-labelledby="bh-posts-h"><h2 id="bh-posts-h">Latest from ${blog.title.replace(/^The /, 'the ')}</h2>++${blog.description ? h`<p class="sc-lede">${blog.description}</p>` : ''}++${feeds.length ? h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}" type="${f.mime}">${f.label}</a> `)}</p>` : ''}++${items.length ? h`<ol class="bh-cards">${items.map(postCard)}</ol>${raw(ssr.paginationHtml(pager))}` : h`<p class="empty">Nothing published here yet.</p>`}</section>++${week.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-week-h"><h2 id="bh-week-h">This week across OpenVibe</h2>++<p class="sc-lede">${plural(stats.weekChanges, 'change')} shipped to ${plural(stats.weekSites, 'site')} in the last seven days. Patch notes on this blog round them up.</p>++<ol class="bh-bars">${week.map((w) => h`<li><a href="https://openvibe.network/updates?site=${encodeURIComponent(w.service)}">${siteName(w.service)}</a><span class="bh-bar" aria-hidden="true"><span style="width:${String(Math.max(2, Math.round((w.changes / top) * 100)))}%"></span></span><span class="bh-n">${plural(w.changes, 'change')}</span></li>`)}</ol>++<p class="bh-more"><a href="https://openvibe.network/updates">Every change, as it ships</a>${series.length ? h` · <a href="${series[0].url}">${series[0].title}</a>` : ''}</p></section>` : ''}++${desks.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-desks-h"><h2 id="bh-desks-h">From the AI desks</h2>++<p class="sc-lede">Daily and weekly digests of what public sources published, every item linked, plus a satire column. Written by AI and not reviewed by a person; kept out of search engines.</p>++<ul class="bh-desks">${desks.map(({ desk, post }) => h`<li><p class="bh-desk">${desk.title} <span class="badge">${desk.satire ? 'Satire' : 'AI'}</span></p><h3><a href="/desks/${desk.key}/${post.slug}">${post.title}</a></h3>${post.dek ? h`<p class="bh-sum">${post.dek}</p>` : ''}<p class="meta">${time(post.published_at)} · <a href="/desks/${desk.key}">More from this desk</a></p></li>`)}</ul>++<p class="bh-more"><a href="/desks">Every desk and its feed</a></p></section>` : ''}++${tags.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-tags-h"><h2 id="bh-tags-h">Topics</h2><p class="sc-lede">Tags across every public post, with how many posts carry each.</p>++<ul class="bh-chips">${tags.map((t) => h`<li><a href="/tags/${encodeURIComponent(t.slug)}">#${t.name} <span>${num(t.posts)}</span></a></li>`)}</ul>++${series.length ? h`<p class="bh-more">Series: ${series.map((s, i) => h`${i ? ' · ' : ''}<a href="${s.url}">${s.title}</a> (${plural(s.posts, 'post')})`)}</p>` : ''}</section>` : ''}++${memberItems.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-members-h"><h2 id="bh-members-h">From members' blogs</h2><ol class="bh-cards">${memberItems.map(postCard)}</ol></section>` : ''}++${raw(showcase.features({++ title: 'What your blog gets',++ items: [++ { icon: 'ov:blog', title: 'Drafts and revisions', text: 'Every save is a revision you can go back to; publish when it is ready.' },++ { icon: 'ov:history', title: 'Scheduling', text: 'Pick the minute a post goes live.' },++ { icon: 'ov:news', title: 'Feeds', text: 'RSS, Atom and JSON Feed for every blog, series and category.' },++ { icon: 'ov:community', title: 'Comments', text: 'Threads on OpenVibe.Community, moderated by you.' },++ { icon: 'ov:theme', title: 'Your theme', text: 'Pick a theme preset and your blog wears it.' },++ { icon: 'ov:vip', title: 'Members-only posts', text: 'Your OpenVibe.VIP members read the whole post; everyone else sees a teaser.' },++ ],++ }))}++${raw(showcase.steps({++ title: 'How it works',++ items: [++ { title: 'Sign in and name your blog', text: 'Your OpenVibe account is all it takes. Pick a title and the address your blog lives at.', href: '/write' },++ { title: 'Write, or start from an AI draft', text: 'Markdown, pictures from OpenVibe.Media, tags, categories and series. Every save is a revision.' },++ { title: 'Publish now or schedule it', text: 'Public, unlisted, for your VIP members or private, one post at a time.' },++ { title: 'Readers follow along', text: 'Feeds for the blog, each series and each category, and comments on OpenVibe.Community.' },++ ],++ }))}++<section class="sc-sec bh-sec bh-faq" aria-labelledby="bh-faq-h"><h2 id="bh-faq-h">Questions</h2>${FAQ.map((f) => h`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>++<section class="sc-sec bh-sec" aria-labelledby="bh-links-h"><h2 id="bh-links-h">Find your way around</h2><ul class="bh-links">++<li><a href="/write"><b>Write</b><small>Your blog, its drafts and its settings.</small></a></li>++<li><a href="/desks"><b>AI desks</b><small>News, food safety, deals, hosting, crowdfunding, developer digests and satire.</small></a></li>++${series.length ? h`<li><a href="${series[0].url}"><b>${series[0].title}</b><small>Every change to OpenVibe, rounded up.</small></a></li>` : ''}++<li><a href="/feed.xml"><b>Feeds</b><small>RSS here; Atom and JSON Feed too.</small></a></li>++<li><a href="/updates"><b>What shipped</b><small>Every change to OpenVibe.Blog itself.</small></a></li>++<li><a href="https://openvibe.network/updates"><b>Network changelog</b><small>Every change to every OpenVibe site.</small></a></li>++</ul></section>++${raw(showcase.cta({ title: 'Start your blog today', text: 'Drafts, scheduling, feeds and comments come with it, and readers need no account.', actions: [start] }))}`);++}++++/** Structured data for the home: the site, the newest posts as an ItemList, and the questions shown. */++function homeJsonLd({ base, items }) {++ const out = [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Blog', url: `${base}/` }];++ if (items.length) out.push({ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Latest posts on OpenVibe.Blog', numberOfItems: items.length, itemListElement: items.map((x, i) => ({ '@type': 'ListItem', position: i + 1, name: x.rev.fields.title, url: `${base}${x.url}` })) });++ out.push({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) });++ return out;++}++++module.exports = { homeBody, homeJsonLd, siteName, FAQ };@@@ -79,7 +79,8 @@ function renderPage(o) {site: 'blog',siteName: SITE_NAME,lang: o.lang,-- title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,++ // fullTitle: a page whose title already names the site (the home); everything else is "<page> · OpenVibe.Blog".++ title: o.fullTitle || (o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME),description: o.description || 'The official OpenVibe blog and a blog for every member.',canonical: o.canonical,decision: o.decision,@@@ -57,10 +57,10 @@ function feedLinksHtml(feeds) {return h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}" type="${f.mime}">${f.label}</a> `)}</p>`;}--/** A blog's front page. Under the network blog's showcase (its hero is the page's h1) the blog's title is an h2. */--function blogIndex({ blog, blogUrl, items, pager, feeds, series, categories, canWrite, showcase = false }) {++/** A blog's front page (the network's home, the official blog's first page, is render/home.js). */++function blogIndex({ blog, blogUrl, items, pager, feeds, series, categories, canWrite }) {return String(h`<header class="blog-header">--${showcase ? h`<h2>${blog.title}</h2>` : h`<h1>${blog.title}</h1>`}++<h1>${blog.title}</h1>${blog.description ? h`<p class="lede">${blog.description}</p>` : ''}${raw(feedLinksHtml(feeds))}${canWrite ? h`<p><a class="button" href="/write/@${blog.handle}">Write on this blog</a></p>` : ''}nnew file mode 100644@@@ -0,0 +1,97 @@++'use strict';++/**++ * The home page (the official blog's first page): its own title and description, the numbers (public posts, AI desk++ * posts, the week's changes and sites), the official blog's newest posts as cards with the pager, the week's changes by++ * site, the AI desks' latest posts (hidden ones never), topics with counts, the series, members' public posts (never a++ * private one), the questions shown and given as FAQPage, and WebSite + ItemList structured data. Page 2 stays a plain++ * listing with the blog's own title.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { createHome } = require('../server/domain/home');++++const LONG = 'The quick brown fox jumps over the lazy dog while the network ships. '.repeat(12);++const ld = (text) => [...text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice', { display_name: 'Alice' });++ const publish = async (as, handle, input) => {++ const r = await t.get(`/api/v1/blogs/${handle}/posts`, { as, json: { body: `${input.title} body.\n\n${LONG}`, ...input } });++ assert.strictEqual(r.status, 201, r.text);++ const p = await t.get(`/api/v1/posts/${r.json().post.id}/publish`, { as, json: {} });++ assert.strictEqual(p.status, 200, p.text);++ t.clock.advance(60_000);++ return r.json().post;++ };++ const now = t.clock.now();++ const iso = (ms) => new Date(ms).toISOString();++ const db = t.ctx.store.db;++++ await check('setup: official posts in a series, a member blog with a public and a private post, changelog entries, desk posts', async () => {++ await publish(t.official, 'openvibe', { title: 'Patch notes: Voice channels', tags: 'patch-notes, chat', series: 'Patch notes' });++ await publish(t.official, 'openvibe', { title: 'Patch notes: The Rundown', tags: 'patch-notes, live', series: 'Patch notes' });++ await publish(t.official, 'openvibe', { title: 'A letter from the team', tags: 'live' });++ assert.strictEqual((await t.get('/api/v1/blogs', { as: alice, json: {} })).status, 201);++ await publish(alice, 'alice', { title: 'My first stream', tags: 'live' });++ await publish(alice, 'alice', { title: 'Private notes', visibility: 'private' });++ const entry = db.prepare(`INSERT INTO changelog_entries (service, repo, sha, subject, deployed_at, release) VALUES (?, ?, ?, ?, ?, 'r1')`);++ let n = 0;++ for (const [service, ago] of [['live', 1], ['live', 2], ['live', 3], ['wiki', 1], ['media-hub', 5], ['news', 9]]) await entry.run(service, `OpenVibers/${service}`, `sha${n++}`, `change ${n}`, iso(now - ago * 86400_000));++ const desk = db.prepare(`INSERT INTO blog_desk_posts (id, desk, slug, status, title, dek, period_from, period_to, created_at, published_at, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);++ await desk.run('dp_news', 'news', 'headlines-oct-21', 'published', 'Headlines of Oct 21', 'What governments announced.', now - 2 * 86400_000, now - 86400_000, now, now, now);++ await desk.run('dp_satire', 'satire', 'too-many-vibes', 'published', 'Is it possible to take too many vibes?', 'A sober inquiry.', now - 86400_000, now, now, now, now);++ await desk.run('dp_hidden', 'deals', 'hidden-deals', 'hidden', 'A hidden digest', null, now - 86400_000, now, now, now, now);++ });++++ let home;++ await check('the home: its own title and description, the numbers, the hero', async () => {++ home = await t.get('/');++ assert.strictEqual(home.status, 200);++ assert.match(home.text, /<title>OpenVibe\.Blog: start a blog, read what OpenVibe ships<\/title>/);++ const desc = /<meta name="description" content="([^"]*)"/.exec(home.text)[1];++ assert.ok(desc.length <= 160 && /drafts, scheduling, feeds and comments/.test(desc), desc);++ assert.strictEqual((home.text.match(/<h1\b/g) || []).length, 1, 'one h1 (the hero)');++ const stats = await createHome({ store: t.ctx.store }).stats();++ assert.deepStrictEqual(stats, { posts: 4, blogs: 2, writers: 2, deskPosts: 2, weekChanges: 5, weekSites: 3 });++ assert.match(home.text, /<dt>Posts published<\/dt><dd>4<\/dd>/);++ assert.match(home.text, /<dt>Changes shipped this week<\/dt><dd>5<\/dd>/);++ });++++ await check('the official blog\'s newest posts as cards; the week by site; the desks; topics; series; members\' posts', async () => {++ const text = home.text;++ assert.ok(text.indexOf('A letter from the team') < text.indexOf('Patch notes: The Rundown'), 'newest first');++ assert.ok(text.includes('class="bh-cards"'));++ assert.match(text, /OpenVibe\.Live<\/a><span class="bh-bar"[^>]*><span style="width:100%"><\/span><\/span><span class="bh-n">3 changes<\/span>/);++ assert.ok(text.includes('OpenVibe.MediaHub') && !text.includes('OpenVibe.News</a><span class="bh-bar"'), 'nine days ago is not this week');++ assert.ok(text.includes('href="/desks/news/headlines-oct-21"') && text.includes('href="/desks/satire/too-many-vibes"'));++ assert.ok(!text.includes('A hidden digest'), 'a hidden desk post never shows');++ assert.match(text, /<a href="\/tags\/live">#live <span>3<\/span><\/a>/);++ assert.ok(text.includes('href="/@openvibe/series/patch-notes">Patch notes</a> (2 posts)'));++ assert.ok(text.includes('My first stream') && !text.includes('Private notes'), 'members\' public posts only');++ });++++ await check('questions, links, structured data', async () => {++ const text = home.text;++ assert.ok(text.includes('<summary>Who can start a blog?</summary>'));++ assert.ok(text.includes('href="/write"') && text.includes('href="/desks"') && text.includes('href="/updates"'));++ const blocks = ld(text);++ assert.ok(blocks.find((b) => b['@type'] === 'Blog') && blocks.find((b) => b['@type'] === 'WebSite'));++ const list = blocks.find((b) => b['@type'] === 'ItemList');++ assert.deepStrictEqual(list.itemListElement.map((x) => x.name), ['A letter from the team', 'Patch notes: The Rundown', 'Patch notes: Voice channels']);++ const faq = blocks.find((b) => b['@type'] === 'FAQPage');++ for (const q of faq.mainEntity) assert.ok(text.includes(`<summary>${q.name.replace(/'/g, ''')}</summary>`), q.name);++ });++++ await check('an empty network: sections with nothing to show are left out', async () => {++ await db.prepare('DELETE FROM changelog_entries').run();++ await db.prepare('DELETE FROM blog_desk_posts').run();++ const text = (await t.get('/')).text;++ assert.ok(!text.includes('This week across OpenVibe') && !text.includes('From the AI desks'));++ assert.ok(!text.includes('<dt>Changes shipped this week</dt>'));++ });++++ await t.close();++ done();++})();@@@ -67,7 +67,7 @@ const split = (html) => ({ head: html.slice(0, html.indexOf('</head>')), body: hassert.strictEqual(count(home, /<title>/g), 1);assert.ok(home.includes('<link rel="canonical" href="https://openvibe.blog/">'));// The home carries the Blog JSON-LD and, since llms-full.txt, the site summary's WebPage JSON-LD.-- assert.deepStrictEqual([...home.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']), ['Blog', 'WebPage']);++ assert.deepStrictEqual([...home.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']), ['Blog', 'WebSite', 'FAQPage', 'WebPage'], 'the Blog, the site, the questions shown (no posts yet: no ItemList) and the AI summary');assert.match(home, /<meta name="ai-summary" content="[^"]+">/, 'the AI summary head');assert.ok(home.includes('<link rel="alternate" type="application/atom+xml" href="/atom.xml" title="The OpenVibe blog (Atom)">'));assert.ok(home.includes('<link rel="alternate" type="application/feed+json" href="/feed.json" title="The OpenVibe blog (JSON Feed)">'));@@@ -14,14 +14,14 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');// 2026-09-29: the home opens with the showcase hero and features (six inline ring icons) and links the cached// /shared/showcase.css: measured html 26.2 KB (6.8 br), css 15.9 KB (4.0 br). Raised as a decision.const BUDGETS = {-- htmlRawKB: 29, // measured 17.4 (fresh database)-- htmlBrotliKB: 7.5, // 4.5++ htmlRawKB: 36, // 33.5 on 2026-10-22: the home's numbers panel, how-it-works steps, questions (and their FAQPage JSON-LD), links around the site and closing call; 17.4 (fresh database)++ htmlBrotliKB: 9, // 8.2 (2026-10-22 the richer home); 4.5jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)jsBrotliKB: 60, // 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)cssFiles: 2, // 1-- cssRawKB: 18.5, // 17.7 (2026-10-15: blog.css carries the AI desks' few rules; the shared showcase.css grew to ~12 KB)-- cssBrotliKB: 4.5, // 1.4++ cssRawKB: 24, // 22 on 2026-10-22 (the home's post cards, week bars, desks, topics, questions and links); 17.7 (2026-10-15: blog.css carries the AI desks' few rules; the shared showcase.css grew to ~12 KB)++ cssBrotliKB: 5.5, // 5.1 (2026-10-22); 1.4externalFiles: 1, // 0};
@@ -70,3 +70,51 @@ table.posts th, table.posts td { text-align: left; padding: 6px 8px; border-bott
@@ -70,3 +70,51 @@ table.posts th, table.posts td { text-align: left; padding: 6px 8px; border-bott
.page .sc-sec { margin-top: 36px; }
.page .sc-sec { margin-top: 36px; }
.page .sc-sec + #posts { display: block; margin-top: 40px; }
.page .sc-sec + #posts { display: block; margin-top: 40px; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
+
+/* The home (render/home.js): a wider column, the numbers beside the hero, post cards, the week by site, desks, topics. */
+body.blog-home .page { max-width: 1120px; }
+.bh-panel { padding: 18px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-card, #131c2e); }
+.page .bh-panel-h { margin: 0 0 12px; font-size: 1rem; }
+.bh-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 0; }
+.bh-stats div { display: flex; flex-direction: column-reverse; padding: 10px 12px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-primary, #0a0f1c); }
+.bh-stats dt { font-size: 13px; line-height: 1.35; color: var(--text-secondary, #96a7c2); }
+.bh-stats dd { margin: 0; font-size: 1.55rem; font-weight: 800; line-height: 1.2; font-variant-numeric: tabular-nums; }
+.bh-sec .feeds { margin: 0 0 14px; font-size: .95rem; }
+.bh-cards, .bh-desks { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); }
+.bh-card, .bh-desks li { display: flex; flex-direction: column; min-width: 0; padding: 14px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #131c2e); }
+.bh-card h3, .bh-desks h3 { margin: 0 0 4px; font-size: 1.05rem; line-height: 1.35; }
+.bh-card h3 a, .bh-desks h3 a { color: var(--text-primary, #e6edf7); text-decoration: none; }
+.bh-card h3 a:hover, .bh-desks h3 a:hover { text-decoration: underline; }
+.bh-card .meta, .bh-desks .meta { margin: 0; color: var(--text-secondary, #96a7c2); }
+.bh-sum { margin: 6px 0 0; font-size: .95rem; color: var(--text-secondary, #96a7c2); display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
+.bh-card .tags { margin: auto 0 0; padding-top: 8px; font-size: .9rem; }
+.bh-desks .meta { margin-top: auto; padding-top: 8px; }
+.bh-desk { margin: 0 0 6px; font-size: .8rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
+.bh-bars { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
+.bh-bars li { display: grid; grid-template-columns: minmax(0, 11rem) minmax(0, 1fr) auto; align-items: center; gap: 12px; font-size: .95rem; }
+.bh-bars a { overflow-wrap: anywhere; }
+.bh-bar { height: 10px; border-radius: 999px; background: var(--bg-tertiary, #172238); overflow: hidden; }
+.bh-bar span { display: block; height: 100%; border-radius: inherit; background: var(--accent, #3b82f6); }
+.bh-n { color: var(--text-secondary, #96a7c2); font-variant-numeric: tabular-nums; white-space: nowrap; }
+.bh-more { margin: 14px 0 0; }
+.bh-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
+.bh-chips a { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border: 1px solid var(--border-light, #2c3d5c); border-radius: 999px; text-decoration: none; font-size: .95rem; }
+.bh-chips a:hover { border-color: var(--accent, #3b82f6); }
+.bh-chips span { color: var(--text-secondary, #96a7c2); font-size: .85rem; }
+.bh-faq details { border-bottom: 1px solid var(--border, #1f2d47); padding: 10px 0; }
+.bh-faq summary { cursor: pointer; font-weight: 600; }
+.bh-faq details p { margin: 8px 0 0; color: var(--text-secondary, #96a7c2); max-width: 760px; }
+.bh-links { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr)); }
+.bh-links a { display: block; height: 100%; padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; }
+.bh-links a:hover { border-color: var(--accent, #3b82f6); }
+.bh-links b { display: block; }
+.bh-links small { display: block; color: var(--text-secondary, #96a7c2); font-size: .88rem; line-height: 1.45; }
+@media (max-width: 560px) {
+ .bh-bars li { grid-template-columns: minmax(0, 1fr) auto; }
+ .bh-bars .bh-bar { grid-column: 1 / -1; grid-row: 2; }
+ .bh-stats dd { font-size: 1.3rem; }
+ /* Phones: a post card is its title, date and tags (the titles say what is inside); desks keep their one-line dek. */
+ .bh-card { padding: 12px 14px; }
+ .bh-card .bh-sum { display: none; }
+ .bh-desks .bh-sum { -webkit-line-clamp: 2; line-clamp: 2; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+'use strict';
+/**
+ * What the home page shows besides the official blog's own posts, all read from Blog's database and all public:
+ *
+ * stats() public posts and the blogs and writers behind them, AI desk posts, and the changes the
+ * changelog logged across the network in the last seven days (and how many sites they touched)
+ * weekChanges(n) those seven days' changes by site, most first
+ * topTags(n) the tags on the most public posts
+ * memberPosts(n) the newest public posts on members' blogs
+ *
+ * Only public, published posts on active blogs count, the rule the listings use (posts.listPublished): the home is cached
+ * for everyone, so nothing here may depend on who is looking.
+ */
+const WEEK_MS = 7 * 24 * 3600 * 1000;
+const PUBLIC_POST = "p.state = 'published' AND p.visibility = 'public' AND EXISTS (SELECT 1 FROM blogs b WHERE b.id = p.blog_id AND b.status = 'active')";
+
+function createHome({ store }) {
+ const db = store.db;
+ const since = () => new Date(store.now() - WEEK_MS).toISOString();
+ const q = {
+ stats: db.prepare(`SELECT (SELECT COUNT(*) FROM blog_posts p WHERE ${PUBLIC_POST}) AS posts,
+ (SELECT COUNT(DISTINCT p.blog_id) FROM blog_posts p WHERE ${PUBLIC_POST}) AS blogs,
+ (SELECT COUNT(DISTINCT p.author_subject) FROM blog_posts p WHERE ${PUBLIC_POST}) AS writers,
+ (SELECT COUNT(*) FROM blog_desk_posts WHERE status = 'published') AS desk_posts,
+ (SELECT COUNT(*) FROM changelog_entries WHERE deployed_at >= ?) AS week_changes,
+ (SELECT COUNT(DISTINCT service) FROM changelog_entries WHERE deployed_at >= ?) AS week_sites`),
+ week: db.prepare('SELECT service, COUNT(*) AS n FROM changelog_entries WHERE deployed_at >= ? GROUP BY service ORDER BY n DESC, service LIMIT ?'),
+ tags: db.prepare(`SELECT t.slug, t.name, COUNT(*) AS n FROM blog_term_links l JOIN blog_terms t ON t.id = l.term_id
+ JOIN blog_posts p ON p.id = l.entity_id WHERE t.vocabulary = 'tag' AND ${PUBLIC_POST}
+ GROUP BY t.slug, t.name ORDER BY n DESC, t.slug LIMIT ?`),
+ memberPosts: db.prepare(`SELECT p.* FROM blog_posts p JOIN blogs mb ON mb.id = p.blog_id AND mb.kind = 'member'
+ WHERE ${PUBLIC_POST} ORDER BY p.published_at DESC, p.id DESC LIMIT ?`),
+ };
+ const n = (v) => Number(v) || 0;
+ return {
+ async stats() {
+ const t = since();
+ const r = (await q.stats.get(t, t)) || {};
+ return { posts: n(r.posts), blogs: n(r.blogs), writers: n(r.writers), deskPosts: n(r.desk_posts), weekChanges: n(r.week_changes), weekSites: n(r.week_sites) };
+ },
+ async weekChanges(limit = 10) {
+ return (await q.week.all(since(), Math.min(50, Math.max(1, limit)))).map((r) => ({ service: r.service, changes: n(r.n) }));
+ },
+ async topTags(limit = 24) {
+ return (await q.tags.all(Math.min(100, Math.max(1, limit)))).map((r) => ({ slug: r.slug, name: r.name, posts: n(r.n) }));
+ },
+ async memberPosts(limit = 6) {
+ return await q.memberPosts.all(Math.min(50, Math.max(1, limit)));
+ },
+ };
+}
+
+module.exports = { createHome, WEEK_MS };
@@ -26,12 +26,19 @@ const authorship = require('openvibe-publishing/authorship');
@@ -26,12 +26,19 @@ const authorship = require('openvibe-publishing/authorship');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { renderPage, SITE_SUMMARY } = require('../render/layout');
const { renderPage, SITE_SUMMARY } = require('../render/layout');
const pages = require('../render/pages');
const pages = require('../render/pages');
+const homePage = require('../render/home');
+const { createHome } = require('../domain/home');
+
+// The home's own words in search results and share cards (the official blog's later pages keep the blog's).
+const HOME_TITLE = 'OpenVibe.Blog: start a blog, read what OpenVibe ships';
+const HOME_DESCRIPTION = 'Start a blog with drafts, scheduling, feeds and comments on your OpenVibe account. Read OpenVibe\'s patch notes and AI digests of the news.';
const { csrfToken, checkCsrf } = require('../auth/forms');
const { csrfToken, checkCsrf } = require('../auth/forms');
const PER_PAGE = 15;
const PER_PAGE = 15;
function createPublicRoutes(ctx) {
function createPublicRoutes(ctx) {
- const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers, limits } = ctx;
+ const { config, store, blogs, posts, publication, reading, people, community, media, access, entitlements, viewers, limits, desks } = ctx;
+ const homeData = ctx.home || createHome({ store });
const router = express.Router();
const router = express.Router();
router.use(viewers.middleware({ services: false }));
router.use(viewers.middleware({ services: false }));
@@ -82,26 +89,6 @@ function createPublicRoutes(ctx) {
@@ -82,26 +89,6 @@ function createPublicRoutes(ctx) {
// ── Blog front pages ────────────────────────────────────
// ── Blog front pages ────────────────────────────────────
- /** The product's own words above the official blog's posts: only features Blog has today. */
- function blogShowcase() {
- return showcase.hero({
- eyebrow: 'OpenVibe.Blog',
- title: 'Write on', accent: 'OpenVibe',
- lede: 'A blog of your own with drafts, scheduling, feeds and comments. Sign in once with your OpenVibe account; anyone can read without one.',
- actions: [{ label: 'Start writing', href: '/write', primary: true }, { label: 'Read the OpenVibe blog', href: '#posts' }, { label: 'AI desks', href: '/desks' }],
- }) + showcase.features({
- title: 'What your blog gets',
- items: [
- { icon: 'ov:blog', title: 'Drafts and revisions', text: 'Every save is a revision you can go back to; publish when it is ready.' },
- { icon: 'ov:history', title: 'Scheduling', text: 'Pick the minute a post goes live.' },
- { icon: 'ov:news', title: 'Feeds', text: 'RSS, Atom and JSON Feed for every blog, series and category.' },
- { icon: 'ov:community', title: 'Comments', text: 'Threads on OpenVibe.Community, moderated by you.' },
- { icon: 'ov:theme', title: 'Your theme', text: 'Pick a theme preset and your blog wears it.' },
- { icon: 'ov:vip', title: 'Members-only posts', text: 'Your OpenVibe.VIP members read the whole post; everyone else sees a teaser.' },
- ],
- }) + '<div id="posts"></div>';
- }
-
async function blogFront(req, res, blog, { home = false } = {}) {
async function blogFront(req, res, blog, { home = false } = {}) {
const page = pageNumber(req);
const page = pageNumber(req);
const scope = await access.listingScope(store, req.viewer, blog);
const scope = await access.listingScope(store, req.viewer, blog);
@@ -125,6 +112,27 @@ function createPublicRoutes(ctx) {
@@ -125,6 +112,27 @@ function createPublicRoutes(ctx) {
};
};
const seriesShown = [];
const seriesShown = [];
for (const sr of await blogs.series(blog)) if (await shows({ seriesId: sr.id })) seriesShown.push(sr);
for (const sr of await blogs.series(blog)) if (await shows({ seriesId: sr.id })) seriesShown.push(sr);
+ const blogLd = blog.kind === 'official'
+ ? { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined, publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' } }
+ : { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined };
+ // The network's home (the official blog's first page): what OpenVibe.Blog is for, then everything it has to read.
+ if (home && pager.page === 1) {
+ const [stats, week, latestDesks, tags, memberRows, seriesCounts] = await Promise.all([
+ homeData.stats(), homeData.weekChanges(10), desks ? desks.latest() : new Map(), homeData.topTags(24), homeData.memberPosts(6),
+ Promise.all(seriesShown.map(async (sr) => ({ title: sr.title, url: `/@${blog.handle}/series/${encodeURIComponent(sr.slug)}`, posts: (await posts.listPublished({ blogId: blog.id, seriesId: sr.id, ...scope, limit: 1 })).total }))),
+ ]);
+ const memberItems = memberRows.length ? await reading.listItems(memberRows) : [];
+ return send(req, res, 200, {
+ title: blog.title, fullTitle: HOME_TITLE, description: HOME_DESCRIPTION, summary: SITE_SUMMARY,
+ decision: pageDecision(canonical, { empty: false, query: ['page'] }),
+ canonical, feeds, theme: { slug: blog.theme, vars: blogs.themeVars(blog) }, lang: blog.language,
+ next: pager.next ? pager.next.href : null,
+ jsonLd: [blogLd, ...homePage.homeJsonLd({ base: config.baseUrl.replace(/\/+$/, ''), items })],
+ styles: [showcase.STYLESHEET], bodyClass: 'blog-home',
+ body: homePage.homeBody({ blog, items, pager, feeds, stats, week, latestDesks, tags, series: seriesCounts, memberItems, signedIn: req.viewer.kind === 'user' })
+ + frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }),
+ }, { cacheable: !scope.restricted && !scope.restrictedAuthor });
+ }
send(req, res, 200, {
send(req, res, 200, {
title: blog.title,
title: blog.title,
description: blog.description,
description: blog.description,
@@ -137,17 +145,12 @@ function createPublicRoutes(ctx) {
@@ -137,17 +145,12 @@ function createPublicRoutes(ctx) {
lang: blog.language,
lang: blog.language,
prev: pager.prev ? pager.prev.href : null,
prev: pager.prev ? pager.prev.href : null,
next: pager.next ? pager.next.href : null,
next: pager.next ? pager.next.href : null,
- jsonLd: [blog.kind === 'official'
- ? { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined, publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network' } }
- : { '@context': 'https://schema.org', '@type': 'Blog', '@id': `${publication.abs(path)}#blog`, name: blog.title, url: publication.abs(path), description: blog.description || undefined }],
- // The network's blog home opens with what OpenVibe.Blog is for (openvibe-shared/showcase), then the posts.
- ...(home && pager.page === 1 ? { styles: [showcase.STYLESHEET] } : {}),
- body: (home && pager.page === 1 ? blogShowcase() : '') + pages.blogIndex({
+ jsonLd: [blogLd],
+ body: pages.blogIndex({
blog, blogUrl: `/@${blog.handle}`, items, pager, feeds,
blog, blogUrl: `/@${blog.handle}`, items, pager, feeds,
series: seriesShown, categories: await prune(await reading.categoriesTree(blog)),
series: seriesShown, categories: await prune(await reading.categoriesTree(blog)),
canWrite: await access.canWrite(store, req.viewer, blog, 'create'),
canWrite: await access.canWrite(store, req.viewer, blog, 'create'),
- showcase: home && pager.page === 1,
- }) + (home && pager.page === 1 ? frame.shipped({ service: 'blog', title: 'Recently shipped on OpenVibe.Blog' }) : ''),
+ }),
}, { cacheable: !scope.restricted && !scope.restrictedAuthor });
}, { cacheable: !scope.restricted && !scope.restrictedAuthor });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+/**
+ * The home page (the first page of the official blog at /): what OpenVibe.Blog is for, then real content only. The
+ * official blog's newest posts, the week's changes across the network, the AI desks' latest posts, topics, members'
+ * posts, how a blog works, questions and the ways around the site. A section with nothing to show is left out.
+ */
+const ssr = require('openvibe-publishing/ssr');
+const showcase = require('openvibe-shared/showcase');
+const catalog = require('../desks/catalog');
+
+const { html: h, raw } = ssr;
+const dateLabel = (v) => (v ? new Date(v).toISOString().slice(0, 10) : null);
+const time = (v) => raw(ssr.timeTag(v == null ? null : new Date(v).toISOString(), { label: dateLabel(v) || undefined }));
+const num = (n) => Number(n || 0).toLocaleString('en-US');
+const plural = (n, one, many = `${one}s`) => `${num(n)} ${n === 1 ? one : many}`;
+
+// A changelog service id as people know the site ("media-hub" is OpenVibe.Video's server, "openre" is OpenRestream).
+const SITE_NAMES = { ai: 'OpenVibe.AI', 'media-hub': 'OpenVibe.MediaHub', openre: 'OpenRestream', vip: 'OpenVibe.VIP', sdk: 'OpenVibe.SDK' };
+const siteName = (id) => SITE_NAMES[id] || `OpenVibe.${String(id).charAt(0).toUpperCase()}${String(id).slice(1)}`;
+
+// The questions the home answers, on the page and as FAQPage (exactly what is shown).
+const FAQ = [
+ { q: 'Who can start a blog?', a: 'Anyone with an OpenVibe account. Sign in, pick your blog\'s name and start writing; reading needs no account at all.' },
+ { q: 'Can other people write on my blog?', a: 'Yes. Add editors, who can publish anyone\'s posts, and authors, who write their own. You stay the owner.' },
+ { q: 'Can AI help me write?', a: 'Ask for a draft from a topic and it arrives as a draft marked as written by AI, with its sources attached. It stays out of search engines until you review it.' },
+ { q: 'Can I write for my members only?', a: 'Yes. Your OpenVibe.VIP members read the whole post; everyone else sees a teaser.' },
+ { q: 'What are the AI desks?', a: 'Digests that OpenVibe.AI writes from the news, recalls, deals, hosting plans and releases OpenVibe.Sources collects, plus one satire column. Every digest links the items it was written from, says it was written by AI, and is kept out of search engines.' },
+ { q: 'Where do the patch notes come from?', a: 'Every change deployed to an OpenVibe site is logged from its repository, and a Patch notes post rounds them up once enough have shipped.' },
+];
+
+function statsPanel(stats) {
+ const tiles = [
+ stats.posts ? ['Posts published', stats.posts] : null,
+ stats.deskPosts ? ['AI desk posts', stats.deskPosts] : null,
+ stats.weekChanges ? ['Changes shipped this week', stats.weekChanges] : null,
+ stats.weekSites ? ['Sites changed this week', stats.weekSites] : null,
+ ].filter(Boolean);
+ if (!tiles.length) return '';
+ return String(h`<div class="bh-panel"><h2 class="bh-panel-h">On the blog</h2><dl class="bh-stats">${tiles.map(([k, v]) => h`<div><dt>${k}</dt><dd>${num(v)}</dd></div>`)}</dl></div>`);
+}
+
+function postCard(item) {
+ const { post, rev, url, author, tags, badge } = item;
+ const summary = rev.fields.summary || ssr.markdownToText(rev.content, 220);
+ return h`<li class="bh-card"><h3><a href="${url}">${rev.fields.title}</a>${badge ? h` <span class="badge">${badge}</span>` : ''}</h3>
+<p class="meta">${time(post.published_at)}${author ? h` · ${author.name}` : ''}</p>${summary ? h`<p class="bh-sum">${summary}</p>` : ''}
+${tags && tags.length ? h`<p class="tags">${tags.slice(0, 4).map((t) => h`<a href="${t.url}">#${t.name}</a> `)}</p>` : ''}</li>`;
+}
+
+/**
+ * o: blog, items (the official blog's first page), pager, feeds, stats, week ([{ service, changes }]), latestDesks
+ * (Map desk → post), tags ([{ slug, name, posts }]), series ([{ title, url, posts }]), memberItems, signedIn
+ */
+function homeBody(o) {
+ const { blog, items, pager, feeds, stats, week = [], latestDesks = new Map(), tags = [], series = [], memberItems = [], signedIn = false } = o;
+ const start = { label: signedIn ? 'Start writing' : 'Sign in and start writing', href: '/write', primary: true };
+ const top = week.length ? week[0].changes : 0;
+ const desks = catalog.ALL.map((d) => ({ desk: d, post: latestDesks.get(d.key) })).filter((x) => x.post);
+ return String(h`${raw(showcase.hero({
+ eyebrow: 'OpenVibe.Blog',
+ title: 'Write on', accent: 'OpenVibe',
+ lede: 'A blog of your own with drafts, scheduling, feeds and comments, on the OpenVibe account you already have. Anyone can read without one.',
+ actions: [start, { label: 'Read the OpenVibe blog', href: '#posts' }, { label: 'AI desks', href: '/desks' }],
+ aside: stats ? { html: statsPanel(stats) } : undefined,
+ }))}
+<section class="sc-sec bh-sec" id="posts" aria-labelledby="bh-posts-h"><h2 id="bh-posts-h">Latest from ${blog.title.replace(/^The /, 'the ')}</h2>
+${blog.description ? h`<p class="sc-lede">${blog.description}</p>` : ''}
+${feeds.length ? h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}" type="${f.mime}">${f.label}</a> `)}</p>` : ''}
+${items.length ? h`<ol class="bh-cards">${items.map(postCard)}</ol>${raw(ssr.paginationHtml(pager))}` : h`<p class="empty">Nothing published here yet.</p>`}</section>
+${week.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-week-h"><h2 id="bh-week-h">This week across OpenVibe</h2>
+<p class="sc-lede">${plural(stats.weekChanges, 'change')} shipped to ${plural(stats.weekSites, 'site')} in the last seven days. Patch notes on this blog round them up.</p>
+<ol class="bh-bars">${week.map((w) => h`<li><a href="https://openvibe.network/updates?site=${encodeURIComponent(w.service)}">${siteName(w.service)}</a><span class="bh-bar" aria-hidden="true"><span style="width:${String(Math.max(2, Math.round((w.changes / top) * 100)))}%"></span></span><span class="bh-n">${plural(w.changes, 'change')}</span></li>`)}</ol>
+<p class="bh-more"><a href="https://openvibe.network/updates">Every change, as it ships</a>${series.length ? h` · <a href="${series[0].url}">${series[0].title}</a>` : ''}</p></section>` : ''}
+${desks.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-desks-h"><h2 id="bh-desks-h">From the AI desks</h2>
+<p class="sc-lede">Daily and weekly digests of what public sources published, every item linked, plus a satire column. Written by AI and not reviewed by a person; kept out of search engines.</p>
+<ul class="bh-desks">${desks.map(({ desk, post }) => h`<li><p class="bh-desk">${desk.title} <span class="badge">${desk.satire ? 'Satire' : 'AI'}</span></p><h3><a href="/desks/${desk.key}/${post.slug}">${post.title}</a></h3>${post.dek ? h`<p class="bh-sum">${post.dek}</p>` : ''}<p class="meta">${time(post.published_at)} · <a href="/desks/${desk.key}">More from this desk</a></p></li>`)}</ul>
+<p class="bh-more"><a href="/desks">Every desk and its feed</a></p></section>` : ''}
+${tags.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-tags-h"><h2 id="bh-tags-h">Topics</h2><p class="sc-lede">Tags across every public post, with how many posts carry each.</p>
+<ul class="bh-chips">${tags.map((t) => h`<li><a href="/tags/${encodeURIComponent(t.slug)}">#${t.name} <span>${num(t.posts)}</span></a></li>`)}</ul>
+${series.length ? h`<p class="bh-more">Series: ${series.map((s, i) => h`${i ? ' · ' : ''}<a href="${s.url}">${s.title}</a> (${plural(s.posts, 'post')})`)}</p>` : ''}</section>` : ''}
+${memberItems.length ? h`<section class="sc-sec bh-sec" aria-labelledby="bh-members-h"><h2 id="bh-members-h">From members' blogs</h2><ol class="bh-cards">${memberItems.map(postCard)}</ol></section>` : ''}
+${raw(showcase.features({
+ title: 'What your blog gets',
+ items: [
+ { icon: 'ov:blog', title: 'Drafts and revisions', text: 'Every save is a revision you can go back to; publish when it is ready.' },
+ { icon: 'ov:history', title: 'Scheduling', text: 'Pick the minute a post goes live.' },
+ { icon: 'ov:news', title: 'Feeds', text: 'RSS, Atom and JSON Feed for every blog, series and category.' },
+ { icon: 'ov:community', title: 'Comments', text: 'Threads on OpenVibe.Community, moderated by you.' },
+ { icon: 'ov:theme', title: 'Your theme', text: 'Pick a theme preset and your blog wears it.' },
+ { icon: 'ov:vip', title: 'Members-only posts', text: 'Your OpenVibe.VIP members read the whole post; everyone else sees a teaser.' },
+ ],
+ }))}
+${raw(showcase.steps({
+ title: 'How it works',
+ items: [
+ { title: 'Sign in and name your blog', text: 'Your OpenVibe account is all it takes. Pick a title and the address your blog lives at.', href: '/write' },
+ { title: 'Write, or start from an AI draft', text: 'Markdown, pictures from OpenVibe.Media, tags, categories and series. Every save is a revision.' },
+ { title: 'Publish now or schedule it', text: 'Public, unlisted, for your VIP members or private, one post at a time.' },
+ { title: 'Readers follow along', text: 'Feeds for the blog, each series and each category, and comments on OpenVibe.Community.' },
+ ],
+ }))}
+<section class="sc-sec bh-sec bh-faq" aria-labelledby="bh-faq-h"><h2 id="bh-faq-h">Questions</h2>${FAQ.map((f) => h`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>
+<section class="sc-sec bh-sec" aria-labelledby="bh-links-h"><h2 id="bh-links-h">Find your way around</h2><ul class="bh-links">
+<li><a href="/write"><b>Write</b><small>Your blog, its drafts and its settings.</small></a></li>
+<li><a href="/desks"><b>AI desks</b><small>News, food safety, deals, hosting, crowdfunding, developer digests and satire.</small></a></li>
+${series.length ? h`<li><a href="${series[0].url}"><b>${series[0].title}</b><small>Every change to OpenVibe, rounded up.</small></a></li>` : ''}
+<li><a href="/feed.xml"><b>Feeds</b><small>RSS here; Atom and JSON Feed too.</small></a></li>
+<li><a href="/updates"><b>What shipped</b><small>Every change to OpenVibe.Blog itself.</small></a></li>
+<li><a href="https://openvibe.network/updates"><b>Network changelog</b><small>Every change to every OpenVibe site.</small></a></li>
+</ul></section>
+${raw(showcase.cta({ title: 'Start your blog today', text: 'Drafts, scheduling, feeds and comments come with it, and readers need no account.', actions: [start] }))}`);
+}
+
+/** Structured data for the home: the site, the newest posts as an ItemList, and the questions shown. */
+function homeJsonLd({ base, items }) {
+ const out = [{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'OpenVibe.Blog', url: `${base}/` }];
+ if (items.length) out.push({ '@context': 'https://schema.org', '@type': 'ItemList', name: 'Latest posts on OpenVibe.Blog', numberOfItems: items.length, itemListElement: items.map((x, i) => ({ '@type': 'ListItem', position: i + 1, name: x.rev.fields.title, url: `${base}${x.url}` })) });
+ out.push({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: FAQ.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) });
+ return out;
+}
+
+module.exports = { homeBody, homeJsonLd, siteName, FAQ };
@@ -79,7 +79,8 @@ function renderPage(o) {
@@ -79,7 +79,8 @@ function renderPage(o) {
site: 'blog',
site: 'blog',
siteName: SITE_NAME,
siteName: SITE_NAME,
lang: o.lang,
lang: o.lang,
- title: o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME,
+ // fullTitle: a page whose title already names the site (the home); everything else is "<page> · OpenVibe.Blog".
+ title: o.fullTitle || (o.title ? `${o.title} · ${SITE_NAME}` : SITE_NAME),
description: o.description || 'The official OpenVibe blog and a blog for every member.',
description: o.description || 'The official OpenVibe blog and a blog for every member.',
canonical: o.canonical,
canonical: o.canonical,
decision: o.decision,
decision: o.decision,
@@ -57,10 +57,10 @@ function feedLinksHtml(feeds) {
@@ -57,10 +57,10 @@ function feedLinksHtml(feeds) {
return h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}" type="${f.mime}">${f.label}</a> `)}</p>`;
return h`<p class="feeds">Follow: ${feeds.map((f) => h`<a href="${f.href}" type="${f.mime}">${f.label}</a> `)}</p>`;
}
}
-/** A blog's front page. Under the network blog's showcase (its hero is the page's h1) the blog's title is an h2. */
-function blogIndex({ blog, blogUrl, items, pager, feeds, series, categories, canWrite, showcase = false }) {
+/** A blog's front page (the network's home, the official blog's first page, is render/home.js). */
+function blogIndex({ blog, blogUrl, items, pager, feeds, series, categories, canWrite }) {
return String(h`<header class="blog-header">
return String(h`<header class="blog-header">
-${showcase ? h`<h2>${blog.title}</h2>` : h`<h1>${blog.title}</h1>`}
+<h1>${blog.title}</h1>
${blog.description ? h`<p class="lede">${blog.description}</p>` : ''}
${blog.description ? h`<p class="lede">${blog.description}</p>` : ''}
${raw(feedLinksHtml(feeds))}
${raw(feedLinksHtml(feeds))}
${canWrite ? h`<p><a class="button" href="/write/@${blog.handle}">Write on this blog</a></p>` : ''}
${canWrite ? h`<p><a class="button" href="/write/@${blog.handle}">Write on this blog</a></p>` : ''}
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+/**
+ * The home page (the official blog's first page): its own title and description, the numbers (public posts, AI desk
+ * posts, the week's changes and sites), the official blog's newest posts as cards with the pager, the week's changes by
+ * site, the AI desks' latest posts (hidden ones never), topics with counts, the series, members' public posts (never a
+ * private one), the questions shown and given as FAQPage, and WebSite + ItemList structured data. Page 2 stays a plain
+ * listing with the blog's own title.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { createHome } = require('../server/domain/home');
+
+const LONG = 'The quick brown fox jumps over the lazy dog while the network ships. '.repeat(12);
+const ld = (text) => [...text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice', { display_name: 'Alice' });
+ const publish = async (as, handle, input) => {
+ const r = await t.get(`/api/v1/blogs/${handle}/posts`, { as, json: { body: `${input.title} body.\n\n${LONG}`, ...input } });
+ assert.strictEqual(r.status, 201, r.text);
+ const p = await t.get(`/api/v1/posts/${r.json().post.id}/publish`, { as, json: {} });
+ assert.strictEqual(p.status, 200, p.text);
+ t.clock.advance(60_000);
+ return r.json().post;
+ };
+ const now = t.clock.now();
+ const iso = (ms) => new Date(ms).toISOString();
+ const db = t.ctx.store.db;
+
+ await check('setup: official posts in a series, a member blog with a public and a private post, changelog entries, desk posts', async () => {
+ await publish(t.official, 'openvibe', { title: 'Patch notes: Voice channels', tags: 'patch-notes, chat', series: 'Patch notes' });
+ await publish(t.official, 'openvibe', { title: 'Patch notes: The Rundown', tags: 'patch-notes, live', series: 'Patch notes' });
+ await publish(t.official, 'openvibe', { title: 'A letter from the team', tags: 'live' });
+ assert.strictEqual((await t.get('/api/v1/blogs', { as: alice, json: {} })).status, 201);
+ await publish(alice, 'alice', { title: 'My first stream', tags: 'live' });
+ await publish(alice, 'alice', { title: 'Private notes', visibility: 'private' });
+ const entry = db.prepare(`INSERT INTO changelog_entries (service, repo, sha, subject, deployed_at, release) VALUES (?, ?, ?, ?, ?, 'r1')`);
+ let n = 0;
+ for (const [service, ago] of [['live', 1], ['live', 2], ['live', 3], ['wiki', 1], ['media-hub', 5], ['news', 9]]) await entry.run(service, `OpenVibers/${service}`, `sha${n++}`, `change ${n}`, iso(now - ago * 86400_000));
+ const desk = db.prepare(`INSERT INTO blog_desk_posts (id, desk, slug, status, title, dek, period_from, period_to, created_at, published_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
+ await desk.run('dp_news', 'news', 'headlines-oct-21', 'published', 'Headlines of Oct 21', 'What governments announced.', now - 2 * 86400_000, now - 86400_000, now, now, now);
+ await desk.run('dp_satire', 'satire', 'too-many-vibes', 'published', 'Is it possible to take too many vibes?', 'A sober inquiry.', now - 86400_000, now, now, now, now);
+ await desk.run('dp_hidden', 'deals', 'hidden-deals', 'hidden', 'A hidden digest', null, now - 86400_000, now, now, now, now);
+ });
+
+ let home;
+ await check('the home: its own title and description, the numbers, the hero', async () => {
+ home = await t.get('/');
+ assert.strictEqual(home.status, 200);
+ assert.match(home.text, /<title>OpenVibe\.Blog: start a blog, read what OpenVibe ships<\/title>/);
+ const desc = /<meta name="description" content="([^"]*)"/.exec(home.text)[1];
+ assert.ok(desc.length <= 160 && /drafts, scheduling, feeds and comments/.test(desc), desc);
+ assert.strictEqual((home.text.match(/<h1\b/g) || []).length, 1, 'one h1 (the hero)');
+ const stats = await createHome({ store: t.ctx.store }).stats();
+ assert.deepStrictEqual(stats, { posts: 4, blogs: 2, writers: 2, deskPosts: 2, weekChanges: 5, weekSites: 3 });
+ assert.match(home.text, /<dt>Posts published<\/dt><dd>4<\/dd>/);
+ assert.match(home.text, /<dt>Changes shipped this week<\/dt><dd>5<\/dd>/);
+ });
+
+ await check('the official blog\'s newest posts as cards; the week by site; the desks; topics; series; members\' posts', async () => {
+ const text = home.text;
+ assert.ok(text.indexOf('A letter from the team') < text.indexOf('Patch notes: The Rundown'), 'newest first');
+ assert.ok(text.includes('class="bh-cards"'));
+ assert.match(text, /OpenVibe\.Live<\/a><span class="bh-bar"[^>]*><span style="width:100%"><\/span><\/span><span class="bh-n">3 changes<\/span>/);
+ assert.ok(text.includes('OpenVibe.MediaHub') && !text.includes('OpenVibe.News</a><span class="bh-bar"'), 'nine days ago is not this week');
+ assert.ok(text.includes('href="/desks/news/headlines-oct-21"') && text.includes('href="/desks/satire/too-many-vibes"'));
+ assert.ok(!text.includes('A hidden digest'), 'a hidden desk post never shows');
+ assert.match(text, /<a href="\/tags\/live">#live <span>3<\/span><\/a>/);
+ assert.ok(text.includes('href="/@openvibe/series/patch-notes">Patch notes</a> (2 posts)'));
+ assert.ok(text.includes('My first stream') && !text.includes('Private notes'), 'members\' public posts only');
+ });
+
+ await check('questions, links, structured data', async () => {
+ const text = home.text;
+ assert.ok(text.includes('<summary>Who can start a blog?</summary>'));
+ assert.ok(text.includes('href="/write"') && text.includes('href="/desks"') && text.includes('href="/updates"'));
+ const blocks = ld(text);
+ assert.ok(blocks.find((b) => b['@type'] === 'Blog') && blocks.find((b) => b['@type'] === 'WebSite'));
+ const list = blocks.find((b) => b['@type'] === 'ItemList');
+ assert.deepStrictEqual(list.itemListElement.map((x) => x.name), ['A letter from the team', 'Patch notes: The Rundown', 'Patch notes: Voice channels']);
+ const faq = blocks.find((b) => b['@type'] === 'FAQPage');
+ for (const q of faq.mainEntity) assert.ok(text.includes(`<summary>${q.name.replace(/'/g, ''')}</summary>`), q.name);
+ });
+
+ await check('an empty network: sections with nothing to show are left out', async () => {
+ await db.prepare('DELETE FROM changelog_entries').run();
+ await db.prepare('DELETE FROM blog_desk_posts').run();
+ const text = (await t.get('/')).text;
+ assert.ok(!text.includes('This week across OpenVibe') && !text.includes('From the AI desks'));
+ assert.ok(!text.includes('<dt>Changes shipped this week</dt>'));
+ });
+
+ await t.close();
+ done();
+})();
@@ -67,7 +67,7 @@ const split = (html) => ({ head: html.slice(0, html.indexOf('</head>')), body: h
@@ -67,7 +67,7 @@ const split = (html) => ({ head: html.slice(0, html.indexOf('</head>')), body: h
assert.strictEqual(count(home, /<title>/g), 1);
assert.strictEqual(count(home, /<title>/g), 1);
assert.ok(home.includes('<link rel="canonical" href="https://openvibe.blog/">'));
assert.ok(home.includes('<link rel="canonical" href="https://openvibe.blog/">'));
// The home carries the Blog JSON-LD and, since llms-full.txt, the site summary's WebPage JSON-LD.
// The home carries the Blog JSON-LD and, since llms-full.txt, the site summary's WebPage JSON-LD.
- assert.deepStrictEqual([...home.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']), ['Blog', 'WebPage']);
+ assert.deepStrictEqual([...home.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']), ['Blog', 'WebSite', 'FAQPage', 'WebPage'], 'the Blog, the site, the questions shown (no posts yet: no ItemList) and the AI summary');
assert.match(home, /<meta name="ai-summary" content="[^"]+">/, 'the AI summary head');
assert.match(home, /<meta name="ai-summary" content="[^"]+">/, 'the AI summary head');
assert.ok(home.includes('<link rel="alternate" type="application/atom+xml" href="/atom.xml" title="The OpenVibe blog (Atom)">'));
assert.ok(home.includes('<link rel="alternate" type="application/atom+xml" href="/atom.xml" title="The OpenVibe blog (Atom)">'));
assert.ok(home.includes('<link rel="alternate" type="application/feed+json" href="/feed.json" title="The OpenVibe blog (JSON Feed)">'));
assert.ok(home.includes('<link rel="alternate" type="application/feed+json" href="/feed.json" title="The OpenVibe blog (JSON Feed)">'));
@@ -14,14 +14,14 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -14,14 +14,14 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// 2026-09-29: the home opens with the showcase hero and features (six inline ring icons) and links the cached
// 2026-09-29: the home opens with the showcase hero and features (six inline ring icons) and links the cached
// /shared/showcase.css: measured html 26.2 KB (6.8 br), css 15.9 KB (4.0 br). Raised as a decision.
// /shared/showcase.css: measured html 26.2 KB (6.8 br), css 15.9 KB (4.0 br). Raised as a decision.
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 29, // measured 17.4 (fresh database)
- htmlBrotliKB: 7.5, // 4.5
+ htmlRawKB: 36, // 33.5 on 2026-10-22: the home's numbers panel, how-it-works steps, questions (and their FAQPage JSON-LD), links around the site and closing call; 17.4 (fresh database)
+ htmlBrotliKB: 9, // 8.2 (2026-10-22 the richer home); 4.5
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell adds web-runtime, 2026-10-04)
jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsRawKB: 250, // 246.2 with openvibe-shared 4.2.x (Ask Actor in the navbar, 2026-10-15); 239.1 (212.2 before openvibe-shared/shell's web-runtime.js)
jsBrotliKB: 60, // 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
jsBrotliKB: 60, // 59.1 with openvibe-shared 4.4.0 (every site in the launcher, 2026-10-17); 56.3 (49.9 before web-runtime.js)
cssFiles: 2, // 1
cssFiles: 2, // 1
- cssRawKB: 18.5, // 17.7 (2026-10-15: blog.css carries the AI desks' few rules; the shared showcase.css grew to ~12 KB)
- cssBrotliKB: 4.5, // 1.4
+ cssRawKB: 24, // 22 on 2026-10-22 (the home's post cards, week bars, desks, topics, questions and links); 17.7 (2026-10-15: blog.css carries the AI desks' few rules; the shared showcase.css grew to ~12 KB)
+ cssBrotliKB: 5.5, // 5.1 (2026-10-22); 1.4
externalFiles: 1, // 0
externalFiles: 1, // 0
};
};