openvibers/openvibe.work Commits
Work home shows the board at a glance: open listings, new today and this week, companies hiring, the fields and cities most listings name, the companies with the most openings, the newest jobs that state the pay, and the questions people ask (#60)
Changes
nnew file mode 100644@@@ -0,0 +1,27 @@++/* OpenVibe.Work's home page only (server/http/pages.js): counts, fields, places, companies and questions. Theme tokens++ only, so light and dark both read; every grid fits a 320px phone. */++.home-stats { list-style: none; padding: 0; margin: 32px 0 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr)); }++.home-stats li { border-radius: 14px; border: 1px solid var(--border, #1f2d47); border-top: 3px solid var(--accent, #3b82f6); background: var(--bg-secondary, #0f1729); }++.home-stats a { display: block; height: 100%; box-sizing: border-box; padding: 14px 16px; color: inherit; text-decoration: none; border-radius: 14px; }++.home-stats a:hover, .home-stats a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }++.home-stats b { display: block; font-size: 1.6rem; line-height: 1.1; font-weight: 800; font-variant-numeric: tabular-nums; }++.home-stats span { display: block; margin-top: 4px; font-size: .86rem; color: var(--text-secondary, #96a7c2); }++++.home-cards { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }++.home-cards a { display: flex; flex-direction: column; gap: 3px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; transition: border-color .15s ease, transform .15s ease; }++.home-cards a:hover, .home-cards a:focus-visible { border-color: var(--accent, #3b82f6); transform: translateY(-1px); outline: 0; }++.home-cards b { font-size: 1rem; overflow-wrap: anywhere; }++.home-cards span { font-size: .85rem; color: var(--text-secondary, #96a7c2); }++++.home-chips { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px; }++.home-chips a { display: inline-flex; align-items: baseline; gap: 6px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .92rem; }++.home-chips a:hover, .home-chips a:focus-visible { border-color: var(--accent, #3b82f6); }++.home-chips span { font-size: .78rem; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }++++.home-faq { display: grid; gap: 8px; max-width: 820px; }++.home-faq details { border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }++.home-faq summary { cursor: pointer; padding: 12px 16px; font-weight: 650; }++.home-faq summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 12px; }++.home-faq details p { margin: 0; padding: 0 16px 14px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }++@media (max-width: 520px) { .home-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .home-cards a { padding: 10px 12px; } }++@media (prefers-reduced-motion: reduce) { .home-cards a { transition: none; } .home-cards a:hover { transform: none; } }@@@ -22,6 +22,8 @@ const listing = require('../render/listing');const store = require('../jobs/store');const sources = require('../jobs/sources');const applications = require('../applications');++const seo = require('openvibe-shared/seo');++const { products } = require('openvibe-contracts');const SITE_NAME = 'OpenVibe.Work';const TAGLINE = 'Find work on open job boards.';@@@ -106,37 +108,70 @@ ${facets.type.map((t) => link({ type: t.type }, t.type, filters.type === t.type,};// ── Home ─────────────────────────────────────────────────++ // Everything here is read from the live listings: the counts, the tags and places most listings carry, the++ // companies with the most openings, the newest remote jobs and the newest that state the pay. A block with nothing++ // to show is left out.++ const FAQ = ((products.get('openvibe.work') || {}).faq || []);++ const n = (v) => Number(v || 0).toLocaleString('en-US');++ const sentence = (t) => (t ? t[0].toUpperCase() + t.slice(1) : t);r.get('/', async (req, res) => {-- const [latest, counts, rows] = await Promise.all([store.listRecent(s, 6, { remote: true }), store.countsBySource(s), store.search(s, {}, { limit: 1 })]);-- const total = rows.facets.matches;++ const [latest, counts, sum, salaried] = await Promise.all([store.listRecent(s, 6, { remote: true }), store.countsBySource(s), store.homeSummary(s), store.listSalaried(s, 4)]);const fetches = await store.sourceFetches(s);++ const total = sum.total;++ const base = String(config.baseUrl).replace(/\/+$/, '');const hero = showcase.hero({-- eyebrow: `${SITE_NAME} · job listings with their provenance`,++ eyebrow: `${SITE_NAME} · ${TAGLINE}`,title: 'Find work.',accent: 'Every listing shows where it came from.',-- lede: `${SITE_NAME} gathers job listings from public job boards, keeps the board's own link on every one, and lets you search them. No paywall, no account needed to look. Sign in to keep searches, track the jobs you apply to, and have your OpenVibe.Actor agent help prepare each application.`,-- actions: [{ label: 'Search the listings', href: '#search', primary: true }, { label: 'Where they come from', href: '/sources' }],++ lede: `${SITE_NAME} gathers job listings from public job boards, keeps the board's own link on every one, and lets you search them. No paywall, no account needed to look. Sign in to keep searches, get alerts, track the jobs you apply to, and have your OpenVibe.Actor agent help prepare each application.`,++ actions: [{ label: 'Search the listings', href: '#search', primary: true }, { label: 'Remote jobs', href: '/jobs?remote=true' }, { label: 'Where they come from', href: '/sources' }],note: 'Open source (AGPL-3.0). Listings are shown with the source named and a link to the original — read the full listing there.',});++ const stats = [++ { value: total, label: total === 1 ? 'open listing' : 'open listings', href: '/jobs' },++ { value: sum.day, label: 'new in the last day', href: '/jobs' },++ { value: sum.week, label: 'new this week', href: '/jobs' },++ { value: sum.companies, label: 'companies hiring', href: '#h-hiring' },++ { value: sum.remote, label: 'remote listings', href: '/jobs?remote=true' },++ { value: sources.SOURCES.length, label: 'job boards read', href: '/sources' },++ ].filter((x) => x.value);page(req, res, {index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,-- description: 'Jobs from open job boards, each saying where it came from and when, with saved searches, alerts through OpenVibe.Watch and an application tracker.',-- jsonLd: homeJsonLd(config),-- styles: [showcase.STYLESHEET],++ title: 'Jobs from open job boards, with sources',++ description: `Search ${total ? `${n(total)} ` : ''}jobs from open job boards, each with its source and date, plus saved searches, alerts through OpenVibe.Watch and an application tracker.`,++ jsonLd: [++ ...homeJsonLd(config),++ latest.length ? seo.jsonLd.itemList('Latest remote jobs on OpenVibe.Work', latest.map((row) => ({ name: `${row.title} at ${row.company}`, url: `${base}/jobs/${row.id}` })), { url: `${base}/jobs?remote=true` }) : null,++ FAQ.length ? seo.jsonLd.faq(FAQ.map((f) => ({ q: f.question, a: f.answer }))) : null,++ ],++ styles: [showcase.STYLESHEET], localStyles: ['css/home.css'],body: html`${raw(hero)}++${stats.length ? html`<ul class="home-stats" aria-label="${SITE_NAME} in numbers">${stats.map((x) => html`<li><a href="${x.href}"><b>${n(x.value)}</b><span>${x.label}</span></a></li>`)}</ul>` : ''}<section class="sc-sec" id="search" aria-labelledby="h-search"><h2 id="h-search">Search the listings</h2>${filterForm({ q: '', remote: '', type: '', location: '' }, { action: '/jobs' })}--<p class="muted">${total} listing${total === 1 ? '' : 's'} from ${sources.SOURCES.length} sources, newest first.</p></section>++<p class="muted">${n(total)} listing${total === 1 ? '' : 's'} from ${sources.SOURCES.length} job boards, newest first.${sum.day ? ` ${n(sum.day)} arrived in the last day.` : ''}</p></section>++${sum.tags.length ? html`<section class="sc-sec" aria-labelledby="h-fields"><h2 id="h-fields">Browse by field</h2>++<p class="sc-lede">The labels the boards put on most listings right now. Each opens a search for it.</p>++<ul class="home-cards">${sum.tags.map((t) => html`<li><a href="/jobs?${new URLSearchParams({ q: t.tag })}"><b>${sentence(t.tag)}</b><span>${n(t.count)} listing${t.count === 1 ? '' : 's'}</span></a></li>`)}</ul></section>` : ''}++${sum.places.length ? html`<section class="sc-sec" aria-labelledby="h-places"><h2 id="h-places">Jobs by city</h2>++<p class="sc-lede">The places most listings name. Remote jobs are <a href="/jobs?remote=true">their own search</a>.</p>++<ul class="home-chips">${sum.places.map((p) => html`<li><a href="/jobs?${new URLSearchParams({ location: p.place })}">${p.place} <span>${n(p.count)}</span></a></li>`)}</ul></section>` : ''}<section class="sc-sec" aria-labelledby="h-latest"><h2 id="h-latest">Latest remote jobs</h2>${listing.listingList(latest, { sourceOf, empty: 'No listings yet — the ingest has not run.' })}<p><a class="sc-btn" href="/jobs?remote=true">All remote listings</a></p></section>++${salaried.length ? html`<section class="sc-sec" aria-labelledby="h-pay"><h2 id="h-pay">Newest jobs that state the pay</h2>++<p class="sc-lede">${n(sum.salaried)} listing${sum.salaried === 1 ? '' : 's'} say what the job pays, in the board's own words.</p>++${listing.listingList(salaried, { sourceOf, empty: '' })}</section>` : ''}++${sum.hiring.length ? html`<section class="sc-sec" aria-labelledby="h-hiring"><h2 id="h-hiring">Companies with the most openings</h2>++<p class="sc-lede">Counted across every board. Each opens a search for the company.</p>++<ul class="home-cards">${sum.hiring.map((c) => html`<li><a href="/jobs?${new URLSearchParams({ q: c.company })}"><b>${c.company}</b><span>${n(c.count)} open listing${c.count === 1 ? '' : 's'}</span></a></li>`)}</ul></section>` : ''}<section class="sc-sec" aria-labelledby="h-sources"><h2 id="h-sources">Where the listings come from</h2><p>Each source below is a public job board read through its own API, under its own terms. Every listing on this site names its source and links to the original; we keep a short excerpt and send you to the board for the rest.</p>${table(['Source', 'Listings', 'Last read', 'Terms'], sources.SOURCES.map((src) => {const f = fetches.get(src.id);return [html`<a href="${src.homepage}" rel="noopener">${src.name}</a>`,-- String((counts.get(src.id) || {}).count || 0),++ n((counts.get(src.id) || {}).count || 0),f && f.finished_at ? html`${time(f.finished_at)}${f.ok ? '' : html` <span class="muted small">(the last read failed)</span>`}` : html`<span class="muted">not read yet</span>`,html`${src.attribution} — <a href="/sources#${src.id}">terms as applied</a>`,];@@@ -147,11 +182,13 @@ ${raw(showcase.steps({{ title: 'Gathered from open boards', text: 'A background ingest reads each board\'s public API every few hours, keeps the listing\'s own id and URL, and stores a short excerpt — never the full description.' },{ title: 'Search it, with the source on it', text: 'Filter by text, workplace, job type and location. Every result names the board it came from and links back to it.' },{ title: 'Read it on the board', text: 'The listing page shows the excerpt and sends you to the original listing to read it in full and apply. Nothing is republished.' },-- { title: 'Keep a search', text: 'Sign in to save a search; /saved shows each one with the count of listings that appeared since you last looked.' },++ { title: 'Keep a search, get alerts', text: 'Sign in to save a search and see what is new since you last looked, or follow any search through OpenVibe.Watch.' },{ title: 'Nothing behind a paywall', text: 'Reading and searching is free and needs no account. There is no premium tier on the listings, and listings expire when a board stops showing them.' },{ title: 'Track your applications', text: 'Sign in and press Track this job on a listing: keep your status, notes and next steps in one place, and ask your OpenVibe.Actor agent to help prepare each application.' },],}))}++${FAQ.length ? html`<section class="sc-sec" aria-labelledby="h-faq"><h2 id="h-faq">Questions people ask</h2>++<div class="home-faq">${FAQ.map((f) => html`<details><summary>${f.question}</summary><p>${f.answer}</p></details>`)}</div></section>` : ''}${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sources and their terms, and your saved searches over HTTP. Reading is public.', actions: [{ label: 'The API', href: '/docs' }, { label: 'The sources', href: '/sources' }] }))}`,});});@@@ -156,6 +156,56 @@ const listRecent = (s, limit = 5, { remote = false } = {}) => s.db.many(`SELECT * FROM work_listings WHERE expired = false${remote ? ' AND remote = true' : ''} ORDER BY sort_at DESC, id DESC LIMIT $1`,[Math.min(50, Math.max(1, limit))]);++/**++ * What the home page shows about the whole board, from the live listings only: how many, how many are remote, how++ * many companies, how many arrived in the last day and week, the tags most listings carry, the places most listings++ * name and the companies with the most listings. Held for ten minutes per store (the ingest runs every few hours), so++ * a busy home page costs no aggregate query. A place's count is the same LIKE the location filter applies, so the++ * number on its link is the number the search finds.++ */++const SUMMARY_TTL_MS = 10 * 60 * 1000;++const summaries = new WeakMap();++const NOT_A_PLACE = /^(remote|remote job|anywhere|worldwide|hybrid|global|europe|emea|usa|united states|united kingdom|germany|france|uk)$/i;++async function homeSummary(s, { now = Date.now(), tags = 12, places = 12, companies = 12 } = {}) {++ const hit = summaries.get(s);++ if (hit && now - hit.at < SUMMARY_TTL_MS) return hit.value;++ const iso = (ms) => new Date(ms).toISOString();++ const totals = await s.db.maybe(++ `SELECT count(*) AS total, count(*) FILTER (WHERE remote) AS remote, count(DISTINCT lower(company)) AS companies,++ count(*) FILTER (WHERE first_seen_at > $1) AS day, count(*) FILTER (WHERE first_seen_at > $2) AS week,++ count(*) FILTER (WHERE coalesce(salary, '') <> '') AS salaried++ FROM work_listings WHERE expired = false`, [iso(now - 86400e3), iso(now - 7 * 86400e3)]);++ const tagRows = await s.db.many(++ `SELECT t, count(*) AS n FROM (SELECT lower(trim(unnest(string_to_array(tags, ',')))) AS t FROM work_listings WHERE expired = false) x++ WHERE length(t) >= 3 AND t <> 'remote' GROUP BY t ORDER BY n DESC, t ASC LIMIT $1`, [tags]);++ const placeNames = (await s.db.many(++ `SELECT trim(split_part(location, ',', 1)) AS p, count(*) AS n FROM work_listings++ WHERE expired = false AND coalesce(location, '') <> '' GROUP BY 1 ORDER BY n DESC, p ASC LIMIT $1`, [places * 2]))++ .map((r) => r.p).filter((p) => p && p.length >= 3 && p.length <= 40 && !NOT_A_PLACE.test(p) && !/remote/i.test(p)).slice(0, places);++ const placeRows = [];++ for (const p of placeNames) {++ placeRows.push({ place: p, count: num(await s.db.value(`SELECT count(*) AS n FROM work_listings WHERE expired = false AND ${PARAM.location(1)}`, [like(lower(p))])) });++ }++ placeRows.sort((a, b) => b.count - a.count || a.place.localeCompare(b.place));++ const companyRows = await s.db.many(++ `SELECT mode() WITHIN GROUP (ORDER BY company) AS company, count(*) AS n FROM work_listings++ WHERE expired = false GROUP BY lower(company) ORDER BY n DESC, 1 ASC LIMIT $1`, [companies]);++ const value = {++ total: num(totals && totals.total), remote: num(totals && totals.remote), companies: num(totals && totals.companies),++ day: num(totals && totals.day), week: num(totals && totals.week), salaried: num(totals && totals.salaried),++ tags: tagRows.map((r) => ({ tag: r.t, count: num(r.n) })),++ places: placeRows,++ hiring: companyRows.map((r) => ({ company: r.company, count: num(r.n) })),++ };++ summaries.set(s, { at: now, value });++ return value;++}++++/** The newest live listings that state a salary (the board's own words). */++const listSalaried = (s, limit = 4) => s.db.many(++ `SELECT * FROM work_listings WHERE expired = false AND coalesce(salary, '') <> '' ORDER BY sort_at DESC, id DESC LIMIT $1`,++ [Math.min(20, Math.max(1, limit))]);++/** The ids of the newest live listings, for the sitemap: its own cap, so a big board cannot bloat the file. */const recentIds = (s, limit = 1000) => s.db.many(`SELECT id FROM work_listings WHERE expired = false ORDER BY sort_at DESC, id DESC LIMIT $1`, [Math.min(1000, Math.max(1, limit))]);@@@ -285,7 +335,7 @@ function decodeCursor(v) {}module.exports = {-- search, facets, getListing, countsBySource, listRecent, recentIds, upsertListings, beginFetch, finishFetch, sourceFetches,++ search, facets, getListing, countsBySource, listRecent, listSalaried, homeSummary, recentIds, upsertListings, beginFetch, finishFetch, sourceFetches,expire, listSaved, createSaved, getSaved, deleteSaved, newCount, touchSaved, toWire, toSavedWire, filtersOf,idOf, encodeCursor, decodeCursor, PAGE_SIZE, MAX_PAGE, MAX_SAVED, LISTING_ID,};@@@ -228,6 +228,9 @@ const first = async (t, path) => (await t.get(path)).json();assert.ok(r.text.includes(`href="${url}"`), `the source ${name} is not linked on the home page`);}assert.match(r.text, /no paywall/i, '"how it works" does not say what is behind a paywall (nothing)');++ for (const h of ['Browse by field', 'Jobs by city', 'Companies with the most openings', 'Questions people ask']) assert.ok(r.text.includes(h), `the home has "${h}"`);++ assert.match(r.text, /<li><a href="\/jobs\?q=[^"]+"><b>[^<]+<\/b><span>\d+ open listings?<\/span>/, 'a company card opens a search for it, with its count');++ assert.match(r.text, /"@type":"ItemList"/, 'the latest remote jobs are an ItemList');});await check('search is limited per caller, and past it the API says so with Retry-After', async () => {@@@ -38,11 +38,12 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#maiassert.strictEqual(r.status, 200);const head = r.text.slice(0, r.text.indexOf('</head>'));assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');-- assert.ok(head.includes('<title>OpenVibe.Work: Find work on open job boards.</title>'), 'home title');++ assert.ok(head.includes('<title>Jobs from open job boards, with sources · OpenVibe.Work</title>'), 'home title');assert.ok(head.includes('<link rel="canonical" href="https://openvibe.work/">'), 'canonical');assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);-- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');++ assert.deepStrictEqual(ld.slice(0, 3), ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');++ assert.strictEqual(ld[ld.length - 1], 'FAQPage', 'the questions shown on the home are its FAQPage');for (const tag of ['<meta property="og:title"', 'data-ov-icon="work"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {assert.ok(head.includes(tag), `head has ${tag}`);}@@@ -22,9 +22,9 @@ const BUDGETS = {jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 264, // 239.8jsBrotliKB: 62.5, // 56.5-- cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)-- cssRawKB: 20.5, // 15.0 before the listings; 18.2 after (the search form, the listing list, the source blocks)-- cssBrotliKB: 4.8, // 3.7 before; 4.3 after++ cssFiles: 3, // 3 (app.css, the cached /shared/showcase.css and css/home.css, the curated home's counts, fields, cities, companies and questions, 2026-10-22)++ cssRawKB: 25.5, // 23.1 with css/home.css (2026-10-22); 15.0 before the listings; 18.2 after (the search form, the listing list, the source blocks)++ cssBrotliKB: 6, // 5.5 with css/home.css; 3.7 before; 4.3 afterexternalFiles: 0, // 0};
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+/* OpenVibe.Work's home page only (server/http/pages.js): counts, fields, places, companies and questions. Theme tokens
+ only, so light and dark both read; every grid fits a 320px phone. */
+.home-stats { list-style: none; padding: 0; margin: 32px 0 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr)); }
+.home-stats li { border-radius: 14px; border: 1px solid var(--border, #1f2d47); border-top: 3px solid var(--accent, #3b82f6); background: var(--bg-secondary, #0f1729); }
+.home-stats a { display: block; height: 100%; box-sizing: border-box; padding: 14px 16px; color: inherit; text-decoration: none; border-radius: 14px; }
+.home-stats a:hover, .home-stats a:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; }
+.home-stats b { display: block; font-size: 1.6rem; line-height: 1.1; font-weight: 800; font-variant-numeric: tabular-nums; }
+.home-stats span { display: block; margin-top: 4px; font-size: .86rem; color: var(--text-secondary, #96a7c2); }
+
+.home-cards { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); }
+.home-cards a { display: flex; flex-direction: column; gap: 3px; height: 100%; box-sizing: border-box; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; transition: border-color .15s ease, transform .15s ease; }
+.home-cards a:hover, .home-cards a:focus-visible { border-color: var(--accent, #3b82f6); transform: translateY(-1px); outline: 0; }
+.home-cards b { font-size: 1rem; overflow-wrap: anywhere; }
+.home-cards span { font-size: .85rem; color: var(--text-secondary, #96a7c2); }
+
+.home-chips { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px; }
+.home-chips a { display: inline-flex; align-items: baseline; gap: 6px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .92rem; }
+.home-chips a:hover, .home-chips a:focus-visible { border-color: var(--accent, #3b82f6); }
+.home-chips span { font-size: .78rem; color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }
+
+.home-faq { display: grid; gap: 8px; max-width: 820px; }
+.home-faq details { border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
+.home-faq summary { cursor: pointer; padding: 12px 16px; font-weight: 650; }
+.home-faq summary:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -2px; border-radius: 12px; }
+.home-faq details p { margin: 0; padding: 0 16px 14px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
+@media (max-width: 520px) { .home-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .home-cards a { padding: 10px 12px; } }
+@media (prefers-reduced-motion: reduce) { .home-cards a { transition: none; } .home-cards a:hover { transform: none; } }
@@ -22,6 +22,8 @@ const listing = require('../render/listing');
@@ -22,6 +22,8 @@ const listing = require('../render/listing');
const store = require('../jobs/store');
const store = require('../jobs/store');
const sources = require('../jobs/sources');
const sources = require('../jobs/sources');
const applications = require('../applications');
const applications = require('../applications');
+const seo = require('openvibe-shared/seo');
+const { products } = require('openvibe-contracts');
const SITE_NAME = 'OpenVibe.Work';
const SITE_NAME = 'OpenVibe.Work';
const TAGLINE = 'Find work on open job boards.';
const TAGLINE = 'Find work on open job boards.';
@@ -106,37 +108,70 @@ ${facets.type.map((t) => link({ type: t.type }, t.type, filters.type === t.type,
@@ -106,37 +108,70 @@ ${facets.type.map((t) => link({ type: t.type }, t.type, filters.type === t.type,
};
};
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
+ // Everything here is read from the live listings: the counts, the tags and places most listings carry, the
+ // companies with the most openings, the newest remote jobs and the newest that state the pay. A block with nothing
+ // to show is left out.
+ const FAQ = ((products.get('openvibe.work') || {}).faq || []);
+ const n = (v) => Number(v || 0).toLocaleString('en-US');
+ const sentence = (t) => (t ? t[0].toUpperCase() + t.slice(1) : t);
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
- const [latest, counts, rows] = await Promise.all([store.listRecent(s, 6, { remote: true }), store.countsBySource(s), store.search(s, {}, { limit: 1 })]);
- const total = rows.facets.matches;
+ const [latest, counts, sum, salaried] = await Promise.all([store.listRecent(s, 6, { remote: true }), store.countsBySource(s), store.homeSummary(s), store.listSalaried(s, 4)]);
const fetches = await store.sourceFetches(s);
const fetches = await store.sourceFetches(s);
+ const total = sum.total;
+ const base = String(config.baseUrl).replace(/\/+$/, '');
const hero = showcase.hero({
const hero = showcase.hero({
- eyebrow: `${SITE_NAME} · job listings with their provenance`,
+ eyebrow: `${SITE_NAME} · ${TAGLINE}`,
title: 'Find work.',
title: 'Find work.',
accent: 'Every listing shows where it came from.',
accent: 'Every listing shows where it came from.',
- lede: `${SITE_NAME} gathers job listings from public job boards, keeps the board's own link on every one, and lets you search them. No paywall, no account needed to look. Sign in to keep searches, track the jobs you apply to, and have your OpenVibe.Actor agent help prepare each application.`,
- actions: [{ label: 'Search the listings', href: '#search', primary: true }, { label: 'Where they come from', href: '/sources' }],
+ lede: `${SITE_NAME} gathers job listings from public job boards, keeps the board's own link on every one, and lets you search them. No paywall, no account needed to look. Sign in to keep searches, get alerts, track the jobs you apply to, and have your OpenVibe.Actor agent help prepare each application.`,
+ actions: [{ label: 'Search the listings', href: '#search', primary: true }, { label: 'Remote jobs', href: '/jobs?remote=true' }, { label: 'Where they come from', href: '/sources' }],
note: 'Open source (AGPL-3.0). Listings are shown with the source named and a link to the original — read the full listing there.',
note: 'Open source (AGPL-3.0). Listings are shown with the source named and a link to the original — read the full listing there.',
});
});
+ const stats = [
+ { value: total, label: total === 1 ? 'open listing' : 'open listings', href: '/jobs' },
+ { value: sum.day, label: 'new in the last day', href: '/jobs' },
+ { value: sum.week, label: 'new this week', href: '/jobs' },
+ { value: sum.companies, label: 'companies hiring', href: '#h-hiring' },
+ { value: sum.remote, label: 'remote listings', href: '/jobs?remote=true' },
+ { value: sources.SOURCES.length, label: 'job boards read', href: '/sources' },
+ ].filter((x) => x.value);
page(req, res, {
page(req, res, {
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
- description: 'Jobs from open job boards, each saying where it came from and when, with saved searches, alerts through OpenVibe.Watch and an application tracker.',
- jsonLd: homeJsonLd(config),
- styles: [showcase.STYLESHEET],
+ title: 'Jobs from open job boards, with sources',
+ description: `Search ${total ? `${n(total)} ` : ''}jobs from open job boards, each with its source and date, plus saved searches, alerts through OpenVibe.Watch and an application tracker.`,
+ jsonLd: [
+ ...homeJsonLd(config),
+ latest.length ? seo.jsonLd.itemList('Latest remote jobs on OpenVibe.Work', latest.map((row) => ({ name: `${row.title} at ${row.company}`, url: `${base}/jobs/${row.id}` })), { url: `${base}/jobs?remote=true` }) : null,
+ FAQ.length ? seo.jsonLd.faq(FAQ.map((f) => ({ q: f.question, a: f.answer }))) : null,
+ ],
+ styles: [showcase.STYLESHEET], localStyles: ['css/home.css'],
body: html`${raw(hero)}
body: html`${raw(hero)}
+${stats.length ? html`<ul class="home-stats" aria-label="${SITE_NAME} in numbers">${stats.map((x) => html`<li><a href="${x.href}"><b>${n(x.value)}</b><span>${x.label}</span></a></li>`)}</ul>` : ''}
<section class="sc-sec" id="search" aria-labelledby="h-search"><h2 id="h-search">Search the listings</h2>
<section class="sc-sec" id="search" aria-labelledby="h-search"><h2 id="h-search">Search the listings</h2>
${filterForm({ q: '', remote: '', type: '', location: '' }, { action: '/jobs' })}
${filterForm({ q: '', remote: '', type: '', location: '' }, { action: '/jobs' })}
-<p class="muted">${total} listing${total === 1 ? '' : 's'} from ${sources.SOURCES.length} sources, newest first.</p></section>
+<p class="muted">${n(total)} listing${total === 1 ? '' : 's'} from ${sources.SOURCES.length} job boards, newest first.${sum.day ? ` ${n(sum.day)} arrived in the last day.` : ''}</p></section>
+${sum.tags.length ? html`<section class="sc-sec" aria-labelledby="h-fields"><h2 id="h-fields">Browse by field</h2>
+<p class="sc-lede">The labels the boards put on most listings right now. Each opens a search for it.</p>
+<ul class="home-cards">${sum.tags.map((t) => html`<li><a href="/jobs?${new URLSearchParams({ q: t.tag })}"><b>${sentence(t.tag)}</b><span>${n(t.count)} listing${t.count === 1 ? '' : 's'}</span></a></li>`)}</ul></section>` : ''}
+${sum.places.length ? html`<section class="sc-sec" aria-labelledby="h-places"><h2 id="h-places">Jobs by city</h2>
+<p class="sc-lede">The places most listings name. Remote jobs are <a href="/jobs?remote=true">their own search</a>.</p>
+<ul class="home-chips">${sum.places.map((p) => html`<li><a href="/jobs?${new URLSearchParams({ location: p.place })}">${p.place} <span>${n(p.count)}</span></a></li>`)}</ul></section>` : ''}
<section class="sc-sec" aria-labelledby="h-latest"><h2 id="h-latest">Latest remote jobs</h2>
<section class="sc-sec" aria-labelledby="h-latest"><h2 id="h-latest">Latest remote jobs</h2>
${listing.listingList(latest, { sourceOf, empty: 'No listings yet — the ingest has not run.' })}
${listing.listingList(latest, { sourceOf, empty: 'No listings yet — the ingest has not run.' })}
<p><a class="sc-btn" href="/jobs?remote=true">All remote listings</a></p></section>
<p><a class="sc-btn" href="/jobs?remote=true">All remote listings</a></p></section>
+${salaried.length ? html`<section class="sc-sec" aria-labelledby="h-pay"><h2 id="h-pay">Newest jobs that state the pay</h2>
+<p class="sc-lede">${n(sum.salaried)} listing${sum.salaried === 1 ? '' : 's'} say what the job pays, in the board's own words.</p>
+${listing.listingList(salaried, { sourceOf, empty: '' })}</section>` : ''}
+${sum.hiring.length ? html`<section class="sc-sec" aria-labelledby="h-hiring"><h2 id="h-hiring">Companies with the most openings</h2>
+<p class="sc-lede">Counted across every board. Each opens a search for the company.</p>
+<ul class="home-cards">${sum.hiring.map((c) => html`<li><a href="/jobs?${new URLSearchParams({ q: c.company })}"><b>${c.company}</b><span>${n(c.count)} open listing${c.count === 1 ? '' : 's'}</span></a></li>`)}</ul></section>` : ''}
<section class="sc-sec" aria-labelledby="h-sources"><h2 id="h-sources">Where the listings come from</h2>
<section class="sc-sec" aria-labelledby="h-sources"><h2 id="h-sources">Where the listings come from</h2>
<p>Each source below is a public job board read through its own API, under its own terms. Every listing on this site names its source and links to the original; we keep a short excerpt and send you to the board for the rest.</p>
<p>Each source below is a public job board read through its own API, under its own terms. Every listing on this site names its source and links to the original; we keep a short excerpt and send you to the board for the rest.</p>
${table(['Source', 'Listings', 'Last read', 'Terms'], sources.SOURCES.map((src) => {
${table(['Source', 'Listings', 'Last read', 'Terms'], sources.SOURCES.map((src) => {
const f = fetches.get(src.id);
const f = fetches.get(src.id);
return [
return [
html`<a href="${src.homepage}" rel="noopener">${src.name}</a>`,
html`<a href="${src.homepage}" rel="noopener">${src.name}</a>`,
- String((counts.get(src.id) || {}).count || 0),
+ n((counts.get(src.id) || {}).count || 0),
f && f.finished_at ? html`${time(f.finished_at)}${f.ok ? '' : html` <span class="muted small">(the last read failed)</span>`}` : html`<span class="muted">not read yet</span>`,
f && f.finished_at ? html`${time(f.finished_at)}${f.ok ? '' : html` <span class="muted small">(the last read failed)</span>`}` : html`<span class="muted">not read yet</span>`,
html`${src.attribution} — <a href="/sources#${src.id}">terms as applied</a>`,
html`${src.attribution} — <a href="/sources#${src.id}">terms as applied</a>`,
];
];
@@ -147,11 +182,13 @@ ${raw(showcase.steps({
@@ -147,11 +182,13 @@ ${raw(showcase.steps({
{ title: 'Gathered from open boards', text: 'A background ingest reads each board\'s public API every few hours, keeps the listing\'s own id and URL, and stores a short excerpt — never the full description.' },
{ title: 'Gathered from open boards', text: 'A background ingest reads each board\'s public API every few hours, keeps the listing\'s own id and URL, and stores a short excerpt — never the full description.' },
{ title: 'Search it, with the source on it', text: 'Filter by text, workplace, job type and location. Every result names the board it came from and links back to it.' },
{ title: 'Search it, with the source on it', text: 'Filter by text, workplace, job type and location. Every result names the board it came from and links back to it.' },
{ title: 'Read it on the board', text: 'The listing page shows the excerpt and sends you to the original listing to read it in full and apply. Nothing is republished.' },
{ title: 'Read it on the board', text: 'The listing page shows the excerpt and sends you to the original listing to read it in full and apply. Nothing is republished.' },
- { title: 'Keep a search', text: 'Sign in to save a search; /saved shows each one with the count of listings that appeared since you last looked.' },
+ { title: 'Keep a search, get alerts', text: 'Sign in to save a search and see what is new since you last looked, or follow any search through OpenVibe.Watch.' },
{ title: 'Nothing behind a paywall', text: 'Reading and searching is free and needs no account. There is no premium tier on the listings, and listings expire when a board stops showing them.' },
{ title: 'Nothing behind a paywall', text: 'Reading and searching is free and needs no account. There is no premium tier on the listings, and listings expire when a board stops showing them.' },
{ title: 'Track your applications', text: 'Sign in and press Track this job on a listing: keep your status, notes and next steps in one place, and ask your OpenVibe.Actor agent to help prepare each application.' },
{ title: 'Track your applications', text: 'Sign in and press Track this job on a listing: keep your status, notes and next steps in one place, and ask your OpenVibe.Actor agent to help prepare each application.' },
],
],
}))}
}))}
+${FAQ.length ? html`<section class="sc-sec" aria-labelledby="h-faq"><h2 id="h-faq">Questions people ask</h2>
+<div class="home-faq">${FAQ.map((f) => html`<details><summary>${f.question}</summary><p>${f.answer}</p></details>`)}</div></section>` : ''}
${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sources and their terms, and your saved searches over HTTP. Reading is public.', actions: [{ label: 'The API', href: '/docs' }, { label: 'The sources', href: '/sources' }] }))}`,
${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sources and their terms, and your saved searches over HTTP. Reading is public.', actions: [{ label: 'The API', href: '/docs' }, { label: 'The sources', href: '/sources' }] }))}`,
});
});
});
});
@@ -156,6 +156,56 @@ const listRecent = (s, limit = 5, { remote = false } = {}) => s.db.many(
@@ -156,6 +156,56 @@ const listRecent = (s, limit = 5, { remote = false } = {}) => s.db.many(
`SELECT * FROM work_listings WHERE expired = false${remote ? ' AND remote = true' : ''} ORDER BY sort_at DESC, id DESC LIMIT $1`,
`SELECT * FROM work_listings WHERE expired = false${remote ? ' AND remote = true' : ''} ORDER BY sort_at DESC, id DESC LIMIT $1`,
[Math.min(50, Math.max(1, limit))]);
[Math.min(50, Math.max(1, limit))]);
+/**
+ * What the home page shows about the whole board, from the live listings only: how many, how many are remote, how
+ * many companies, how many arrived in the last day and week, the tags most listings carry, the places most listings
+ * name and the companies with the most listings. Held for ten minutes per store (the ingest runs every few hours), so
+ * a busy home page costs no aggregate query. A place's count is the same LIKE the location filter applies, so the
+ * number on its link is the number the search finds.
+ */
+const SUMMARY_TTL_MS = 10 * 60 * 1000;
+const summaries = new WeakMap();
+const NOT_A_PLACE = /^(remote|remote job|anywhere|worldwide|hybrid|global|europe|emea|usa|united states|united kingdom|germany|france|uk)$/i;
+async function homeSummary(s, { now = Date.now(), tags = 12, places = 12, companies = 12 } = {}) {
+ const hit = summaries.get(s);
+ if (hit && now - hit.at < SUMMARY_TTL_MS) return hit.value;
+ const iso = (ms) => new Date(ms).toISOString();
+ const totals = await s.db.maybe(
+ `SELECT count(*) AS total, count(*) FILTER (WHERE remote) AS remote, count(DISTINCT lower(company)) AS companies,
+ count(*) FILTER (WHERE first_seen_at > $1) AS day, count(*) FILTER (WHERE first_seen_at > $2) AS week,
+ count(*) FILTER (WHERE coalesce(salary, '') <> '') AS salaried
+ FROM work_listings WHERE expired = false`, [iso(now - 86400e3), iso(now - 7 * 86400e3)]);
+ const tagRows = await s.db.many(
+ `SELECT t, count(*) AS n FROM (SELECT lower(trim(unnest(string_to_array(tags, ',')))) AS t FROM work_listings WHERE expired = false) x
+ WHERE length(t) >= 3 AND t <> 'remote' GROUP BY t ORDER BY n DESC, t ASC LIMIT $1`, [tags]);
+ const placeNames = (await s.db.many(
+ `SELECT trim(split_part(location, ',', 1)) AS p, count(*) AS n FROM work_listings
+ WHERE expired = false AND coalesce(location, '') <> '' GROUP BY 1 ORDER BY n DESC, p ASC LIMIT $1`, [places * 2]))
+ .map((r) => r.p).filter((p) => p && p.length >= 3 && p.length <= 40 && !NOT_A_PLACE.test(p) && !/remote/i.test(p)).slice(0, places);
+ const placeRows = [];
+ for (const p of placeNames) {
+ placeRows.push({ place: p, count: num(await s.db.value(`SELECT count(*) AS n FROM work_listings WHERE expired = false AND ${PARAM.location(1)}`, [like(lower(p))])) });
+ }
+ placeRows.sort((a, b) => b.count - a.count || a.place.localeCompare(b.place));
+ const companyRows = await s.db.many(
+ `SELECT mode() WITHIN GROUP (ORDER BY company) AS company, count(*) AS n FROM work_listings
+ WHERE expired = false GROUP BY lower(company) ORDER BY n DESC, 1 ASC LIMIT $1`, [companies]);
+ const value = {
+ total: num(totals && totals.total), remote: num(totals && totals.remote), companies: num(totals && totals.companies),
+ day: num(totals && totals.day), week: num(totals && totals.week), salaried: num(totals && totals.salaried),
+ tags: tagRows.map((r) => ({ tag: r.t, count: num(r.n) })),
+ places: placeRows,
+ hiring: companyRows.map((r) => ({ company: r.company, count: num(r.n) })),
+ };
+ summaries.set(s, { at: now, value });
+ return value;
+}
+
+/** The newest live listings that state a salary (the board's own words). */
+const listSalaried = (s, limit = 4) => s.db.many(
+ `SELECT * FROM work_listings WHERE expired = false AND coalesce(salary, '') <> '' ORDER BY sort_at DESC, id DESC LIMIT $1`,
+ [Math.min(20, Math.max(1, limit))]);
+
/** The ids of the newest live listings, for the sitemap: its own cap, so a big board cannot bloat the file. */
/** The ids of the newest live listings, for the sitemap: its own cap, so a big board cannot bloat the file. */
const recentIds = (s, limit = 1000) => s.db.many(
const recentIds = (s, limit = 1000) => s.db.many(
`SELECT id FROM work_listings WHERE expired = false ORDER BY sort_at DESC, id DESC LIMIT $1`, [Math.min(1000, Math.max(1, limit))]);
`SELECT id FROM work_listings WHERE expired = false ORDER BY sort_at DESC, id DESC LIMIT $1`, [Math.min(1000, Math.max(1, limit))]);
@@ -285,7 +335,7 @@ function decodeCursor(v) {
@@ -285,7 +335,7 @@ function decodeCursor(v) {
}
}
module.exports = {
module.exports = {
- search, facets, getListing, countsBySource, listRecent, recentIds, upsertListings, beginFetch, finishFetch, sourceFetches,
+ search, facets, getListing, countsBySource, listRecent, listSalaried, homeSummary, recentIds, upsertListings, beginFetch, finishFetch, sourceFetches,
expire, listSaved, createSaved, getSaved, deleteSaved, newCount, touchSaved, toWire, toSavedWire, filtersOf,
expire, listSaved, createSaved, getSaved, deleteSaved, newCount, touchSaved, toWire, toSavedWire, filtersOf,
idOf, encodeCursor, decodeCursor, PAGE_SIZE, MAX_PAGE, MAX_SAVED, LISTING_ID,
idOf, encodeCursor, decodeCursor, PAGE_SIZE, MAX_PAGE, MAX_SAVED, LISTING_ID,
};
};
@@ -228,6 +228,9 @@ const first = async (t, path) => (await t.get(path)).json();
@@ -228,6 +228,9 @@ const first = async (t, path) => (await t.get(path)).json();
assert.ok(r.text.includes(`href="${url}"`), `the source ${name} is not linked on the home page`);
assert.ok(r.text.includes(`href="${url}"`), `the source ${name} is not linked on the home page`);
}
}
assert.match(r.text, /no paywall/i, '"how it works" does not say what is behind a paywall (nothing)');
assert.match(r.text, /no paywall/i, '"how it works" does not say what is behind a paywall (nothing)');
+ for (const h of ['Browse by field', 'Jobs by city', 'Companies with the most openings', 'Questions people ask']) assert.ok(r.text.includes(h), `the home has "${h}"`);
+ assert.match(r.text, /<li><a href="\/jobs\?q=[^"]+"><b>[^<]+<\/b><span>\d+ open listings?<\/span>/, 'a company card opens a search for it, with its count');
+ assert.match(r.text, /"@type":"ItemList"/, 'the latest remote jobs are an ItemList');
});
});
await check('search is limited per caller, and past it the API says so with Retry-After', async () => {
await check('search is limited per caller, and past it the API says so with Retry-After', async () => {
@@ -38,11 +38,12 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
@@ -38,11 +38,12 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const head = r.text.slice(0, r.text.indexOf('</head>'));
const head = r.text.slice(0, r.text.indexOf('</head>'));
assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
- assert.ok(head.includes('<title>OpenVibe.Work: Find work on open job boards.</title>'), 'home title');
+ assert.ok(head.includes('<title>Jobs from open job boards, with sources · OpenVibe.Work</title>'), 'home title');
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.work/">'), 'canonical');
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.work/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
+ assert.deepStrictEqual(ld.slice(0, 3), ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
+ assert.strictEqual(ld[ld.length - 1], 'FAQPage', 'the questions shown on the home are its FAQPage');
for (const tag of ['<meta property="og:title"', 'data-ov-icon="work"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
for (const tag of ['<meta property="og:title"', 'data-ov-icon="work"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
assert.ok(head.includes(tag), `head has ${tag}`);
}
}
@@ -22,9 +22,9 @@ const BUDGETS = {
@@ -22,9 +22,9 @@ const BUDGETS = {
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
jsBrotliKB: 62.5, // 56.5
- cssFiles: 2, // 2 (app.css + the cached /shared/showcase.css)
- cssRawKB: 20.5, // 15.0 before the listings; 18.2 after (the search form, the listing list, the source blocks)
- cssBrotliKB: 4.8, // 3.7 before; 4.3 after
+ cssFiles: 3, // 3 (app.css, the cached /shared/showcase.css and css/home.css, the curated home's counts, fields, cities, companies and questions, 2026-10-22)
+ cssRawKB: 25.5, // 23.1 with css/home.css (2026-10-22); 15.0 before the listings; 18.2 after (the search form, the listing list, the source blocks)
+ cssBrotliKB: 6, // 5.5 with css/home.css; 3.7 before; 4.3 after
externalFiles: 0, // 0
externalFiles: 0, // 0
};
};