diff --git a/package.json b/package.json index b5c52e2..0b9b69c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/agenticjobs", - "version": "0.22.5", + "version": "0.22.6", "description": "An agent-friendly job board you self-host. It posts its own jobs, never scrapes anyone else's, and answers on every surface: web, API, MCP, CLI, TUI, desktop and PWA. Instances find each other through an open directory.", "license": "MIT", "type": "module", diff --git a/src/config.ts b/src/config.ts index bcb5e69..288464a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -187,7 +187,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { } /** Kept in step with package.json by the release script. */ -export const VERSION = '0.22.5'; +export const VERSION = '0.22.6'; export const SOFTWARE_NAME = 'agenticjobs'; /** diff --git a/src/views/layout.tsx b/src/views/layout.tsx index 305c33f..d3f17dc 100644 --- a/src/views/layout.tsx +++ b/src/views/layout.tsx @@ -15,7 +15,7 @@ import { renderMarkdown } from '../markup/markdown.ts'; import type { SkillCount } from '../core/skills.ts'; import { pathForQuery } from '../core/landing.ts'; import { EMPTY_QUERY } from '../schema/query.ts'; -import { pfsFooter } from './pfs-footer.ts'; +import { pitFooter } from './pit-footer.ts'; import { AdUnit, showsAd } from './ad-unit.tsx'; export interface PageProps { @@ -267,11 +267,12 @@ export const Layout: FC> = (props) => {
+ {/* The one ad, at the top of the content: never pressed against the footer. */} + {showsAd(path, noindex) && }
{children}
- {showsAd(path, noindex) && } - {skills.length > 0 && ( -
+
+ {skills.length > 0 && ( -
- )} - {raw( - pfsFooter({ - site: `${publicUrl.replace(/\/+$/, '')}/`, - links: [ - { label: 'Popular', href: '/popular' }, - { label: 'Most profitable', href: '/most-profitable' }, - { label: 'Agents', href: '/agents' }, - { label: 'API', href: '/docs' }, - { label: 'OpenAPI', href: '/api/v1/openapi.json' }, - { label: 'Instance', href: '/.well-known/agenticjobs' }, - { label: 'Feed', href: '/jobs.json' }, - { label: 'llms.txt', href: '/llms.txt' }, - { label: 'Source', href: 'https://github.com/profullstack/agenticjobs' }, - ], - tagline: `${boardName} runs on agenticjobs, MIT licensed.`, - }), - )} + )} + +
{/* * CrawlProof, as a plain tag. `defer` is what `strategy` meant on the @@ -312,6 +306,8 @@ export const Layout: FC> = (props) => { defer > )} + {/* The pit.world global footer, last in . */} + {raw(pitFooter(publicUrl))} ); diff --git a/src/views/pfs-footer.ts b/src/views/pfs-footer.ts deleted file mode 100644 index 61ad9d4..0000000 --- a/src/views/pfs-footer.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * The shared Profullstack footer (@profullstack/footer): the board's link row, - * the copyright line and the webring, from the package's @latest template, so a - * release of the package reaches every board without a redeploy. - * - * Layout renders synchronously (pages and tests stringify it directly), so this - * returns the last footer rendered for these options and refreshes it in the - * background on each use; the package caches the template for an hour. Until - * the first refresh lands it is the template from the installed package. - */ -import { footerHtml, footerHtmlSync } from '@profullstack/footer'; - -type FooterOptions = Parameters[0]; - -const rendered = new Map(); - -export function pfsFooter(options: FooterOptions): string { - const key = JSON.stringify(options); - void footerHtml(options) - .then((html) => rendered.set(key, html)) - .catch(() => undefined); - return rendered.get(key) ?? footerHtmlSync(options); -} diff --git a/src/views/pit-footer.ts b/src/views/pit-footer.ts new file mode 100644 index 0000000..a6724e6 --- /dev/null +++ b/src/views/pit-footer.ts @@ -0,0 +1,57 @@ +/** + * The pit.world global footer (site name, search, ring, publish), fetched on + * the server from https://pit.world/snippets/footer.html?site= and + * rendered as the last thing in . + * + * Layout renders synchronously (pages and tests stringify it directly), so this + * returns the last good copy for the host and refreshes it in the background + * once it is an hour old (3s timeout). A failed refresh keeps the old copy; with + * no copy yet it returns '' and the page simply has no global footer. + */ +const MAX_AGE_MS = 60 * 60 * 1000; + +interface Entry { + html: string; + fetchedAt: number; + inflight: Promise | null; +} + +const cache = new Map(); + +function refresh(site: string, entry: Entry): Promise { + entry.inflight ??= fetch( + `https://pit.world/snippets/footer.html?site=${encodeURIComponent(site)}`, + { signal: AbortSignal.timeout(3000) }, + ) + .then(async (res) => { + const text = res.ok ? await res.text() : ''; + if (!text.includes('data-pit-footer')) throw new Error(`pit footer: HTTP ${res.status}`); + entry.html = text; + entry.fetchedAt = Date.now(); + }) + .catch(() => { + entry.fetchedAt = Date.now() - MAX_AGE_MS + 60_000; // try again in a minute + }) + .finally(() => { + entry.inflight = null; + }); + return entry.inflight; +} + +/** The footer fragment for a board's public URL ('' until the first fetch lands). */ +export function pitFooter(publicUrl: string): string { + let site: string; + try { + site = new URL(publicUrl).hostname; + } catch { + return ''; + } + if (!site || site === 'localhost' || site.endsWith('.test')) return ''; + let entry = cache.get(site); + if (!entry) { + entry = { html: '', fetchedAt: 0, inflight: null }; + cache.set(site, entry); + } + if (Date.now() - entry.fetchedAt > MAX_AGE_MS) void refresh(site, entry); + return entry.html; +} diff --git a/web/public/app.css b/web/public/app.css index c1b7b4f..fb54cd0 100644 --- a/web/public/app.css +++ b/web/public/app.css @@ -143,7 +143,7 @@ main { .site-footer { border-top: 1px solid var(--border); - padding: 2rem 0; + padding: 1.25rem 0; color: var(--muted-foreground); font-size: 0.85rem; } @@ -159,17 +159,13 @@ main { color: var(--muted-foreground); } -/* The shared Profullstack footer (src/views/pfs-footer.ts) under the skills row. */ -footer.pfs-footer { - --pfs-footer-muted: var(--muted-foreground); - border-top-color: var(--border); -} - -/* The skill links above the footer proper: one row that wraps. */ -/* The shared footer's own top rule follows, so the row needs none of its own. */ +/* Two rows: the popular skills, then the copyright and the links people use. */ .footer-skills { gap: 0.5rem 0.9rem; } +.footer-skills + .footer-links { + margin-top: 0.75rem; +} .thread.unread { font-weight: 600; @@ -1100,9 +1096,9 @@ a.badge-plain:hover .badge { .tracker-missing { color: var(--warning); font-size: 0.9rem; margin: 0.25rem 0; } .tracker-improve ul { margin: 0; padding-left: 1.25rem; display: grid; gap: 0.5rem; } -/* CrawlProof ad unit (src/views/ad-unit.tsx): one fluid text_link frame above the footer. */ +/* CrawlProof ad unit (src/views/ad-unit.tsx): one fluid text_link frame at the top of the content. */ .ad-unit { - margin: 1.5rem 0 0; + margin: 0 0 1.5rem; } .ad-unit iframe { display: block; diff --git a/web/public/sw.js b/web/public/sw.js index 40d32d8..5bf8202 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -13,7 +13,7 @@ * drifted, which is the only version of this rule that survives contact. */ -const VERSION = '4a4fd178'; +const VERSION = '4442cc3d'; const SHELL = `shell-${VERSION}`; const ASSETS = ['/assets/app.css', '/assets/tokens.css', '/assets/icon.svg'];