@singi-labs/sifa-page-renderer
v0.2.34
Published
Pure HTML renderer for personal sites driven by Sifa profile data. No framework, no filesystem -- import and call.
Maintainers
Readme
@singi-labs/sifa-page-renderer
Pure HTML renderer for personal sites driven by Sifa profile data, styled after academicpages.github.io. No framework, no filesystem -- import and call.
What it does
Takes a Sifa profile object and returns complete standalone HTML pages. Body
sections are built from the structured SDK Profile (via buildProfileSections,
driven by the shared @singi-labs/sifa-sdk section model), so ordering matches
the main Sifa profile page and detail is rich (proper dates, validated links,
publication citations). Each page has:
- Top masthead with horizontal navigation
- Left sidebar with avatar, identity, and links
- Main content area with prose styling
- Sifa-branded footer
- Dark mode toggle (persisted to localStorage)
- Print-friendly layout (hides chrome)
Usage
Self-hosted static site
import { fetchProfile } from '@singi-labs/sifa-sdk/query/fetchers';
import { buildProfileSections, renderHome, renderSectionPage } from '@singi-labs/sifa-page-renderer';
import { CSS } from '@singi-labs/sifa-page-renderer/style';
// Fetch the structured profile from sifa.id
const profile = await fetchProfile({ baseUrl: 'https://sifa.id' }, 'your-handle.bsky.social');
// Build sections from the structured profile (About + Career + ... , Links excluded)
const sections = buildProfileSections(profile);
// Render pages
const indexHtml = renderHome(profile, sections, { year: 2026, updated: '2026-07-15' });
for (const section of sections) {
if (section.slug === 'index') continue; // About is shown on the home page
const html = renderSectionPage(profile, section, sections, { year: 2026 });
// Write to dist/${section.slug}.html
}See sifa-page for a complete self-hosting scaffold.
Server-rendered (Next.js, Fastify, etc.)
import { buildProfileSections, renderSinglePage } from '@singi-labs/sifa-page-renderer';
import { getCSS } from '@singi-labs/sifa-page-renderer/style';
// Override asset paths for your hosting setup. renderSinglePage serves all
// sections in one document with hash-based nav (#career, #education, ...).
const sections = buildProfileSections(profile);
const html = renderSinglePage(profile, sections, {
paths: {
css: '/api/style',
assetDir: '/static/sifa',
fontDir: '/fonts/sifa',
favicon: '/static/sifa/favicon.svg',
},
og: {
title: 'Jane Doe - Personal site',
description: 'Jane Doe on Sifa ID',
url: 'https://example.com/p/jane/site',
},
});API
buildProfileSections(profile): RenderedSection[]
Build every visible body section from a structured SDK Profile, in canonical
order, each rendered to sanitized HTML: { id, slug, title, html }. The Links
section is excluded (it renders in the sidebar). Always the public visitor view
(owner-hidden items dropped).
renderHome(profile, sections, ctx?): string
Render the home/About page. sections is the buildProfileSections output.
Returns a complete HTML document.
renderSectionPage(profile, section, sections, ctx?): string
Render a single section page (Career, Education, etc.). Returns a complete HTML document.
renderSinglePage(profile, sections, ctx?): string
Render all sections in one document with hash-based nav, for server-rendered single-route hosts (e.g. sifa-web). Returns a complete HTML document.
renderActivityStream(vms, options?): string
Render an activity stream to an HTML fragment (a <section>), from an array of
StreamCardVM objects -- the same normalized view-model the sifa-web activity
cards consume (produced by the SDK's toStreamCardVM). Both surfaces render the
same VM, so page.sifa.id and sifa-web stay in lockstep without sharing a DOM.
import { renderActivityStream } from '@singi-labs/sifa-page-renderer';
import type { StreamCardVM } from '@singi-labs/sifa-sdk';
const html = renderActivityStream(vms, {
// Optional. Build an image URL from a blob ref. Media may arrive already
// resolved ({ url }) or as a raw blob ref ({ did, cid }); the VM stays
// host-agnostic, so the host decides the CDN. Default: a Bluesky-style URL
// `${cdnBase}/img/feed_fullsize/plain/{did}/{cid}@jpeg`.
blobUrl: (did, cid) => `https://images.example/${did}/${cid}`,
cdnBase: 'https://cdn.bsky.app', // base for the default blobUrl builder
permalink: (vm) => webUrlFor(vm), // turn the at:// uri into a web link
groupByDay: true, // Today / Yesterday / date headers (UTC)
now: new Date(), // reference point for grouping + times
emptyText: 'No activity yet.',
});Options (all optional):
blobUrl(did, cid): string | null | undefined-- builds an image URL for blob-ref media. Returnnullto skip. Default:${cdnBase}/img/feed_fullsize/plain/{did}/{cid}@jpeg.cdnBase: string-- base for the defaultblobUrlbuilder. Defaulthttps://cdn.bsky.app.permalink(vm): string | null | undefined-- the VM'suriis anat://URI, not a web URL; return an http(s) URL to link the card title. Default: unlinked (per-item permalinks are deferred).groupByDay: boolean-- group items under Today / Yesterday / date headers (UTC, deterministic for server rendering). Defaulttrue.now: Date-- reference "now" for relative times and day grouping. Defaultnew Date().emptyText: string-- shown when the stream is empty. Default"No activity yet.".
All user-controlled strings are HTML-escaped and every URL is scheme-validated
(http/https only), the same way the profile renderer handles profile data. The
card body switches on body.kind (text | media | link | track |
generic); unrecognized future kinds degrade to the text fallback. Style the
output with the .activity-stream / .stream-* rules in getCSS().
renderActivityPage(profile, sections, vms, ctx?, streamOptions?): string
Render a standalone activity ("Now") page: the same masthead + sidebar + footer
layout as renderSectionPage, with renderActivityStream(vms, streamOptions) as
its main content. vms is an array of StreamCardVM (as consumed by
renderActivityStream); streamOptions is forwarded verbatim. Returns a
complete HTML document with <title>Now - {name}</title> and the "Now" nav item
marked active. The page links to now.html (its nav slug is now), so a static
build writes it as dist/now.html.
import { renderActivityPage } from '@singi-labs/sifa-page-renderer';
const nowHtml = renderActivityPage(profile, sections, vms, { year: 2026 }, {
permalink: (vm) => webUrlFor(vm),
});
// Write to dist/now.htmlrenderActivityPage forces the "Now" nav entry on for its own page. To surface
that link on the home page and every section page too, set the
ctx.activityStream flag on those calls (see below).
ctx.activityStream -- the "Now" nav flag
RenderContext accepts an optional activityStream flag that injects the "Now"
nav entry (masthead + mobile bottom nav) into renderHome, renderSectionPage,
and renderSinglePage, linking to now.html. Pass true for the default "Now"
label, or an ActivityNavConfig object to customize it. When omitted, the nav is
byte-identical to a build without an activity page.
ActivityNavConfig accepts:
label-- nav label + page title for the activity page. Default:"Now".href-- href for the "Now" nav entry (masthead + mobile bottom nav). Default:"now.html". Set this so a single-page host (e.g. sifa-web'spage.sifa.id/{handle}route, driven byrenderSinglePage) can point "Now" at a real per-handle URL such as"/gui.do/now"instead of the static file. May be an absolutehttp(s)URL or a same-origin relative path; the value is validated and escaped, and executable schemes likejavascript:are rejected (falling back to"now.html"). Active-state highlighting is keyed on the entry's slug (now), so a custom href still highlights correctly on the activity page.
const ctx = { year: 2026, activityStream: true };
const indexHtml = renderHome(profile, sections, ctx);
const careerHtml = renderSectionPage(profile, career, sections, ctx);
const nowHtml = renderActivityPage(profile, sections, vms, ctx);
// All three now share a nav with an active-on-now.html "Now" link.
// Point "Now" at a per-handle URL (e.g. for a server-rendered single-page host):
const hosted = { activityStream: { href: '/gui.do/now' } };
const singleHtml = renderSinglePage(profile, sections, hosted);ctx.profileHomeHref -- point section links at the single-page home
On a single-page host, the standalone activity page rendered by
renderActivityPage lives at a nested route like page.sifa.id/{handle}/now.
Its section nav (About/Career/…) would otherwise emit relative career.html
links, which resolve to page.sifa.id/{handle}/career.html and 404. Set
ctx.profileHomeHref to the single-page profile home so those links point back
to it instead:
- the About/index section links to
profileHomeHrefitself (e.g./gui.do); - every other section with slug
Slinks toprofileHomeHref+#+S(e.g./gui.do#career).
The rewrite applies to both the masthead and the mobile bottom nav. The "Now"
activity entry keeps its own activityStream.href and active-state, and the
masthead brand badge is untouched. Section active-state stays slug-based.
Like activityStream.href, the value may be an absolute http(s) URL or a
same-origin relative path; it is validated and escaped, and executable schemes
like javascript: are rejected (falling back to the default section links).
When omitted, the nav is byte-identical to today.
const ctx = {
activityStream: { href: '/gui.do/now' },
profileHomeHref: '/gui.do',
};
const nowHtml = renderActivityPage(profile, sections, vms, ctx);
// Section links now point at /gui.do and /gui.do#career; "Now" stays /gui.do/now.ctx.did -- at-tags AT URI meta tags
Set ctx.did to the DID of the account the page renders and every page gains
the at-tags meta tags, which map
the HTML back to the AT record it is built from:
<meta name="at:canonical" content="at://did:plc:xxx/id.sifa.profile.self/self">
<meta name="at:author" content="at://did:plc:xxx">at:canonical points at id.sifa.profile.self because deleting that record is
what would make the page cease to exist, which is the spec's test for canonical.
Per-section records are not emitted as at:alternate: a filled profile spans
13+ collections, and the proposal gives no guidance yet for pages that aggregate
many records.
The value is ignored unless it parses as a DID (did:<method>:<id>), so passing
a handle by mistake emits nothing rather than a bogus AT URI. When omitted, the
<head> is byte-identical to today.
const html = renderHome(profile, sections, { did: 'did:plc:zcanytz...' });parseSections(md: string): ParsedSection[]
Parse a markdown string into ##-keyed sections. Retained for consumers that
still parse the .md export; the renderer itself no longer uses it.
sectionSlug(title: string): string
Convert a section title to a URL-safe slug.
isSidebarOnly(title: string): boolean
Returns true for sections that render in the sidebar (currently "Links").
getCSS(opts?): string
Generate the stylesheet. Pass { fontDir: '/custom/path' } to override font paths.
CSS: string
Default stylesheet (equivalent to getCSS()).
Static assets
The package includes fonts and SVG logos under static/. Import them via the package exports:
@singi-labs/sifa-page-renderer/static/fonts/quattro-regular.woff2
@singi-labs/sifa-page-renderer/static/assets/sifa-logo.svgOr copy them to your build output:
cp -r node_modules/@singi-labs/sifa-page-renderer/static/* dist/Data requirements
The renderer expects a single data input:
- Profile -- an SDK
Profileobject (fromfetchProfile). Identity fields (handle,displayName,headline,about,avatar,website,location*,externalAccounts) drive the sidebar/footer; the section arrays (positions,education,publications, ...) drive the body sections viabuildProfileSections.
License
MIT. See LICENSE.
Fonts: Quattro (SIL Open Font License), Space Grotesk (SIL Open Font License). See static/fonts/LICENSE.txt.
