// Public, server-rendered markdown pages (landing, about, contact, privacy).
//
// Each page is a markdown file in src/content/. The same source is served as
// HTML to browsers and as raw markdown to agents that ask for text/markdown,
// so the two can never drift apart.
import { readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { Marked, type RendererObject, type Tokens } from "marked";
import { parseFrontmatter } from "./frontmatter";
import { escapeHtml } from "./oauth/pages";
const CONTENT_DIR = join(import.meta.dir, "../content");
export const SITE_NAME = "indiko";
export const CONTACT_EMAIL = "hello@dunkirk.sh";
export const SECURITY_EMAIL = "security@dunkirk.sh";
export const REPO_URL = "https://tangled.org/@dunkirk.sh/indiko";
export const SAME_AS = [
REPO_URL,
"https://github.com/taciturnaxolotl/indiko",
"https://dunkirk.sh",
];
export interface ContentPage {
slug: string;
/** URL path this page is served at. */
path: string;
file: string;
/** Sitemap priority for this page. */
priority: string;
}
export const CONTENT_PAGES: ContentPage[] = [
{ slug: "about", path: "/about", file: "about.md", priority: "0.8" },
{ slug: "contact", path: "/contact", file: "contact.md", priority: "0.7" },
{ slug: "privacy", path: "/privacy", file: "privacy.md", priority: "0.5" },
];
export function getContentPage(slug: string): ContentPage | undefined {
return CONTENT_PAGES.find((page) => page.slug === slug);
}
interface ParsedPage {
title: string;
subtitle: string;
description: string;
body: string;
}
function readPage(page: ContentPage): ParsedPage {
const raw = readFileSync(join(CONTENT_DIR, page.file), "utf8");
const { fm, body } = parseFrontmatter(raw);
return {
title: fm.title ?? SITE_NAME,
subtitle: fm.subtitle ?? "",
description: fm.description ?? fm.subtitle ?? "",
body,
};
}
export function lastModified(page: ContentPage): Date {
return statSync(join(CONTENT_DIR, page.file)).mtime;
}
/**
* The markdown an agent gets from `Accept: text/markdown`. Frontmatter is
* replaced by the heading and summary it describes, so the document stands
* on its own.
*/
export function renderMarkdown(page: ContentPage): string {
const { title, subtitle, body } = readPage(page);
const summary = subtitle ? `> ${subtitle}\n\n` : "";
return `# ${title}\n\n${summary}${body}`;
}
/** Wrap each top-level (##) section in a .section card, matching the docs page. */
export function wrapSections(html: string): string {
return html
.split(/(?=
{
const trimmed = part.trim();
if (!trimmed) return "";
return `\n`;
})
.join("");
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/<[^>]+>/g, "")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-");
}
// Headings keep their level so h1 -> h2 -> h3 stays sequential, and get ids
// so agents (and people) can link to a section.
const renderer: RendererObject = {
heading({ tokens, depth }: Tokens.Heading): string {
const text = this.parser.parseInline(tokens);
const id = slugify(text);
return `${text}\n`;
},
table({ header, rows }: Tokens.Table): string {
const head = header
.map((cell) => `| ${this.parser.parseInline(cell.tokens)} | `)
.join("");
const body = rows
.map(
(row) =>
`${row
.map((cell) => `| ${this.parser.parseInline(cell.tokens)} | `)
.join("")}
`,
)
.join("");
return `
\n`;
},
blockquote({ tokens }: Tokens.Blockquote): string {
const inner = this.parser.parse(tokens);
return `${inner}
\n`;
},
};
const marked = new Marked({ renderer });
function organizationNode(origin: string) {
return {
"@type": "Organization",
"@id": `${origin}/#organization`,
name: SITE_NAME,
url: `${origin}/`,
logo: `${origin}/logo.svg`,
image: `${origin}/og.png`,
email: CONTACT_EMAIL,
description:
"Self-hosted IndieAuth, OAuth 2.0, and OpenID Connect identity provider with passkey authentication.",
founder: {
"@type": "Person",
name: "Kieran Klukas",
url: "https://dunkirk.sh",
},
address: {
"@type": "PostalAddress",
addressLocality: "Westerville",
addressRegion: "OH",
postalCode: "43081",
addressCountry: "US",
},
contactPoint: [
{
"@type": "ContactPoint",
contactType: "technical support",
email: CONTACT_EMAIL,
url: `${origin}/contact`,
availableLanguage: ["English"],
},
{
"@type": "ContactPoint",
contactType: "security",
email: SECURITY_EMAIL,
url: `${origin}/.well-known/security.txt`,
availableLanguage: ["English"],
},
],
sameAs: SAME_AS,
};
}
function structuredData(
page: ContentPage,
parsed: ParsedPage,
origin: string,
): string {
const url = `${origin}${page.path}`;
const graph: Record[] = [
organizationNode(origin),
{
"@type": "WebSite",
"@id": `${origin}/#website`,
name: SITE_NAME,
url: `${origin}/`,
description: parsed.description,
inLanguage: "en",
publisher: { "@id": `${origin}/#organization` },
},
{
"@type": "WebPage",
"@id": `${url}#webpage`,
url,
name: parsed.title,
description: parsed.description,
inLanguage: "en",
isPartOf: { "@id": `${origin}/#website` },
about: { "@id": `${origin}/#software` },
primaryImageOfPage: `${origin}/og.png`,
},
];
if (page.slug === "about") {
graph.push({
"@type": "SoftwareApplication",
"@id": `${origin}/#software`,
name: "Indiko",
url: `${origin}/`,
description: parsed.description,
applicationCategory: "SecurityApplication",
applicationSubCategory: "Identity provider",
operatingSystem: "Linux, macOS",
softwareRequirements: "Bun",
softwareHelp: `${origin}/docs`,
license: "https://tangled.org/@dunkirk.sh/indiko/blob/main/LICENSE.md",
codeRepository: REPO_URL,
image: `${origin}/og.png`,
author: {
"@type": "Person",
name: "Kieran Klukas",
url: "https://dunkirk.sh",
},
publisher: { "@id": `${origin}/#organization` },
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
availability: "https://schema.org/InStock",
},
});
}
const json = JSON.stringify({
"@context": "https://schema.org",
"@graph": graph,
});
// `` inside JSON would close the block early; nothing else can.
return json.replace(/<\//g, "<\\/");
}
function footer(): string {
return `
`;
}
const FOOTER_STYLES = `
.site-footer {
text-align: left;
max-width: var(--page-width);
margin: 3rem auto 0;
padding-top: 1.5rem;
border-top: 1px solid var(--accent-deep);
color: var(--paper-dim);
font-size: var(--text-sm);
}
.site-footer nav {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 0.75rem;
}
i-nav .brand {
margin-right: auto;
}
.page-header {
max-width: var(--page-width);
margin: 0 auto 2rem;
}
.page-header .subtitle {
margin-top: 0.5rem;
}`;
function siteNav(): string {
// Hand-written rather than the component: these pages are for
// signed-out readers and agents, so they carry no session chip and no
// script. The i-nav element is kept as the scope root so the design
// system's nav styles still apply. Navigation lives in the footer, so
// this is just the brand and a way in.
return `
`;
}
/** Render a content page as a full HTML document. */
export function renderContentPage(page: ContentPage, origin: string): string {
const parsed = readPage(page);
const html = marked.parse(parsed.body, { async: false }) as string;
const url = `${origin}${page.path}`;
const title = `${parsed.title} • indiko`;
return `
${escapeHtml(title)}
${siteNav()}
${wrapSections(html)}
${footer()}
`;
}