Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions web/scripts/__tests__/static-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,143 @@ describe('generateStaticPages', () => {
expect(bodySection).not.toMatch(/<p[^>]*>[^<]*<li/);
});

it('includes DiscussionForumPosting JSON-LD in proposal pages', () => {
const data = minimalActivityData({
proposals: [
{
number: 80,
title: 'Test JSON-LD proposal',
phase: 'voting',
author: 'hivemoot-forager',
createdAt: '2026-02-21T10:00:00Z',
commentCount: 7,
},
],
});
writeFileSync(
join(TEST_OUT, 'data', 'activity.json'),
JSON.stringify(data)
);

generateStaticPages(TEST_OUT);

const html = readFileSync(
join(TEST_OUT, 'proposal', '80', 'index.html'),
'utf-8'
);
expect(html).toContain('application/ld+json');
expect(html).toContain('DiscussionForumPosting');
expect(html).toContain('https://schema.org');
expect(html).toContain('hivemoot-forager');
expect(html).toContain('"commentCount":7');
expect(html).toContain('"datePublished":"2026-02-21T10:00:00Z"');
});

it('includes ProfilePage JSON-LD in agent pages', () => {
const data = minimalActivityData({
agentStats: [
{
login: 'hivemoot-builder',
commits: 10,
pullRequestsMerged: 5,
issuesOpened: 3,
reviews: 8,
comments: 12,
lastActiveAt: '2026-02-14T00:00:00Z',
},
],
});
writeFileSync(
join(TEST_OUT, 'data', 'activity.json'),
JSON.stringify(data)
);

generateStaticPages(TEST_OUT);

const html = readFileSync(
join(TEST_OUT, 'agent', 'hivemoot-builder', 'index.html'),
'utf-8'
);
expect(html).toContain('application/ld+json');
expect(html).toContain('ProfilePage');
expect(html).toContain('https://schema.org');
expect(html).toContain('hivemoot-builder');
});

it('unicode-escapes < > & in JSON-LD to prevent script injection', () => {
const data = minimalActivityData({
proposals: [
{
number: 81,
title: 'A & B <test> "proposal"',
phase: 'discussion',
author: 'agent',
createdAt: '2026-02-21T00:00:00Z',
commentCount: 0,
},
],
});
writeFileSync(
join(TEST_OUT, 'data', 'activity.json'),
JSON.stringify(data)
);

generateStaticPages(TEST_OUT);

const html = readFileSync(
join(TEST_OUT, 'proposal', '81', 'index.html'),
'utf-8'
);
// The JSON-LD script block must not contain raw < > & inside string values
// Extract the JSON-LD script content
const ldMatch = html.match(
/<script type="application\/ld\+json">([^<]*)<\/script>/
);
expect(ldMatch).not.toBeNull();
const ldContent = ldMatch?.[1] ?? '';
expect(ldContent).not.toContain('</script>');
// Angle brackets and ampersands are unicode-escaped
expect(ldContent).toContain('\\u003c');
expect(ldContent).toContain('\\u003e');
expect(ldContent).toContain('\\u0026');
});

it('percent-encodes bot login names in JSON-LD author URLs', () => {
const data = minimalActivityData({
proposals: [
{
number: 82,
title: 'Bot authored proposal',
phase: 'discussion',
author: 'hivemoot[bot]',
createdAt: '2026-02-21T00:00:00Z',
commentCount: 0,
},
],
});
writeFileSync(
join(TEST_OUT, 'data', 'activity.json'),
JSON.stringify(data)
);

generateStaticPages(TEST_OUT);

const html = readFileSync(
join(TEST_OUT, 'proposal', '82', 'index.html'),
'utf-8'
);
// Extract the JSON-LD script block and verify the author URL is encoded
const ldMatch = html.match(
/<script type="application\/ld\+json">([^<]*)<\/script>/
);
expect(ldMatch).not.toBeNull();
const ldContent = ldMatch?.[1] ?? '';
// The author URL must percent-encode [ and ] as %5B/%5D
expect(ldContent).toContain('hivemoot%5Bbot%5D');
// The raw unencoded form must not appear inside a URL value
expect(ldContent).not.toContain('github.com/hivemoot[bot]');
});

it('renders markdown list items with leading spaces and tabs', () => {
const data = minimalActivityData({
proposals: [
Expand Down
50 changes: 48 additions & 2 deletions web/scripts/static-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,25 @@ interface PageMeta {
title: string;
description: string;
canonicalPath: string;
jsonLd?: object;
}

/**
* Serialize a JSON-LD object as a safe inline <script> tag.
*
* JSON.stringify is safe for embedding in HTML <script> blocks as long as
* the characters `<`, `>`, and `&` are unicode-escaped so that the browser's
* HTML parser never sees a closing `</script>` sequence or an entity that
* could confuse surrounding markup. This is the pattern recommended by
* Google's Search Central documentation.
*/
function jsonLdTag(data: object): string {
const json = JSON.stringify(data).replace(/[<>&]/g, (c) => {
if (c === '<') return '\\u003c';
if (c === '>') return '\\u003e';
return '\\u0026';
});
return `<script type="application/ld+json">${json}</script>`;
}

// -- Phase display helpers --
Expand Down Expand Up @@ -199,6 +218,7 @@ function htmlShell(meta: PageMeta, content: string): string {
<meta name="twitter:title" content="${escapeHtml(meta.title)}" />
<meta name="twitter:description" content="${escapeHtml(meta.description)}" />
<meta name="twitter:image" content="${escapeHtml(BASE_URL)}/og-image.png" />
${meta.jsonLd ? jsonLdTag(meta.jsonLd) : ''}
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; color: #1a1a1a; background: #fffbeb; min-height: 100vh; }
Expand Down Expand Up @@ -253,10 +273,24 @@ function proposalPage(proposal: Proposal): string {

const phaseLine = `${phaseLabel} — proposed by ${proposal.author}. ${proposal.commentCount} comments.${proposal.votesSummary ? ` Votes: ${proposal.votesSummary.thumbsUp} for, ${proposal.votesSummary.thumbsDown} against.` : ''}`;
const excerpt = bodyExcerpt(proposal.body);
const canonicalPath = `/proposal/${proposal.number}/`;
const meta: PageMeta = {
title: `Proposal #${proposal.number}: ${proposal.title} | Colony`,
description: excerpt ? `${phaseLine} ${excerpt}` : phaseLine,
canonicalPath: `/proposal/${proposal.number}/`,
canonicalPath,
jsonLd: {
'@context': 'https://schema.org',
'@type': 'DiscussionForumPosting',
headline: proposal.title,
url: `${BASE_URL}${canonicalPath}`,
datePublished: proposal.createdAt,
author: {
'@type': 'Person',
name: proposal.author,
url: `https://github.com/${encodeURIComponent(proposal.author)}`,
},
commentCount: proposal.commentCount,
},
};

let votesHtml = '';
Expand Down Expand Up @@ -360,10 +394,22 @@ function proposalPage(proposal: Proposal): string {
}

function agentPage(agent: AgentStats): string {
const canonicalPath = `/agent/${encodeURIComponent(agent.login)}/`;
const meta: PageMeta = {
title: `${agent.login} | Colony Agents`,
description: `${agent.login} — ${agent.commits} commits, ${agent.pullRequestsMerged} PRs merged, ${agent.reviews} reviews. Contributing to Colony, the first project built entirely by autonomous agents.`,
canonicalPath: `/agent/${encodeURIComponent(agent.login)}/`,
canonicalPath,
jsonLd: {
'@context': 'https://schema.org',
'@type': 'ProfilePage',
name: `${agent.login} | Colony Agents`,
url: `${BASE_URL}${canonicalPath}`,
mainEntity: {
'@type': 'Person',
name: agent.login,
url: `https://github.com/${encodeURIComponent(agent.login)}`,
},
},
};

const content = `
Expand Down